From 4c4fab6eec3eceec9722026a814737e69ad429fd Mon Sep 17 00:00:00 2001
From: Babel36acl <40179128+AKCX2002@users.noreply.github.com>
Date: Fri, 4 Sep 2026 17:19:17 +0800
Subject: [PATCH] refactor: introduce PM3 backend boundary
---
.github/workflows/build.yml | 67 ----
.github/workflows/release.yml | 42 +--
.github/workflows/sync-pm3.yml | 355 ------------------
README.md | 53 ++-
.../desktop_cli/desktop_cli_backend.dart | 117 ++++++
.../desktop_cli}/pm3_process.dart | 91 ++---
lib/backend/mock/mock_pm3_backend.dart | 66 ++++
lib/core/pm3/pm3_backend.dart | 17 +
lib/core/pm3/pm3_command.dart | 7 +
lib/core/pm3/pm3_connection.dart | 17 +
lib/core/pm3/pm3_controller.dart | 43 +++
lib/core/pm3/pm3_event.dart | 16 +
lib/core/pm3/pm3_result.dart | 18 +
lib/state/app_state.dart | 16 +-
lib/state/connection_state.dart | 55 +--
lib/ui/home_page.dart | 6 +-
lib/ui/pages/connection_page.dart | 17 +-
lib/ui/pages/data_page.dart | 7 +-
lib/ui/pages/hf_14b_page.dart | 2 +-
lib/ui/pages/hf_15_page.dart | 2 +-
lib/ui/pages/hf_emv_page.dart | 2 +-
lib/ui/pages/hf_felica_page.dart | 2 +-
lib/ui/pages/hf_fido_page.dart | 2 +-
lib/ui/pages/hf_iclass_page.dart | 2 +-
lib/ui/pages/hf_legic_page.dart | 2 +-
lib/ui/pages/hf_mfdes_page.dart | 2 +-
lib/ui/pages/hf_mfu_page.dart | 2 +-
lib/ui/pages/hf_seos_page.dart | 2 +-
lib/ui/pages/hf_sniff_page.dart | 2 +-
lib/ui/pages/lf_awid_page.dart | 2 +-
lib/ui/pages/lf_fdxb_page.dart | 2 +-
lib/ui/pages/lf_hid_page.dart | 2 +-
lib/ui/pages/lf_hitag_page.dart | 2 +-
lib/ui/pages/lf_indala_page.dart | 2 +-
lib/ui/pages/lf_io_page.dart | 2 +-
lib/ui/pages/lf_keri_page.dart | 2 +-
lib/ui/pages/lf_page.dart | 2 +-
lib/ui/pages/lf_pyramid_page.dart | 2 +-
lib/ui/pages/mifare_page.dart | 2 +-
lib/ui/pages/nfc_page.dart | 2 +-
lib/ui/pages/script_page.dart | 2 +-
lib/ui/pages/trace_page.dart | 2 +-
test/backend/pm3_controller_test.dart | 46 +++
43 files changed, 483 insertions(+), 621 deletions(-)
delete mode 100644 .github/workflows/sync-pm3.yml
create mode 100644 lib/backend/desktop_cli/desktop_cli_backend.dart
rename lib/{services => backend/desktop_cli}/pm3_process.dart (82%)
create mode 100644 lib/backend/mock/mock_pm3_backend.dart
create mode 100644 lib/core/pm3/pm3_backend.dart
create mode 100644 lib/core/pm3/pm3_command.dart
create mode 100644 lib/core/pm3/pm3_connection.dart
create mode 100644 lib/core/pm3/pm3_controller.dart
create mode 100644 lib/core/pm3/pm3_event.dart
create mode 100644 lib/core/pm3/pm3_result.dart
create mode 100644 test/backend/pm3_controller_test.dart
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1db64f4..12e0c44 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -6,9 +6,6 @@ on:
pull_request:
branches: [main]
workflow_dispatch:
- # 定时校验“最新依赖”兼容性,防止长期不变导致 CI 老化
- schedule:
- - cron: '0 3 * * 1'
permissions:
contents: read
@@ -53,10 +50,6 @@ jobs:
channel: stable
cache: true
- - name: Optional dependency refresh (scheduled)
- if: github.event_name == 'schedule'
- run: flutter pub upgrade --major-versions
-
- name: Get dependencies
run: flutter pub get
@@ -78,62 +71,6 @@ jobs:
name: pm3gui-linux-x64-${{ github.run_number }}
path: pm3gui-linux-x64-*.tar.gz
- build-android:
- name: Android build
- runs-on: ubuntu-latest
- timeout-minutes: 30
- permissions:
- contents: read
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Cache pub packages
- uses: actions/cache@v5
- with:
- path: ~/.pub-cache
- key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
- restore-keys: |
- ${{ runner.os }}-pub-
-
- - name: Cache Gradle
- uses: actions/cache@v5
- with:
- path: |
- ~/.gradle/caches
- ~/.gradle/wrapper
- key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
- restore-keys: |
- ${{ runner.os }}-gradle-
-
- - name: Setup JDK
- uses: actions/setup-java@v5
- with:
- java-version: '21'
- distribution: 'temurin'
-
- - name: Setup Flutter
- uses: subosito/flutter-action@v2
- with:
- channel: stable
- cache: true
-
- - name: Optional dependency refresh (scheduled)
- if: github.event_name == 'schedule'
- run: flutter pub upgrade --major-versions
-
- - name: Get dependencies
- run: flutter pub get
-
- - name: Build APK (split per ABI)
- run: flutter build apk --release --split-per-abi
-
- - name: Upload APK artifacts
- uses: actions/upload-artifact@v7
- with:
- name: pm3gui-android-apk-${{ github.run_number }}
- path: build/app/outputs/flutter-apk/*.apk
-
build-windows:
name: Windows build
runs-on: windows-latest
@@ -150,10 +87,6 @@ jobs:
channel: stable
cache: true
- - name: Optional dependency refresh (scheduled)
- if: github.event_name == 'schedule'
- run: flutter pub upgrade --major-versions
-
- name: Get dependencies
run: flutter pub get
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f160a26..e1cc87c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -53,45 +53,6 @@ jobs:
name: release-linux
path: pm3gui-linux-x64-*.tar.gz
- release-android:
- name: Release Android
- runs-on: ubuntu-latest
- timeout-minutes: 30
- permissions:
- contents: read
- steps:
- - uses: actions/checkout@v6
-
- - name: Cache Gradle
- uses: actions/cache@v5
- with:
- path: |
- ~/.gradle/caches
- ~/.gradle/wrapper
- key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
- restore-keys: |
- ${{ runner.os }}-gradle-
-
- - uses: actions/setup-java@v5
- with:
- java-version: '21'
- distribution: 'temurin'
-
- - name: Setup Flutter
- uses: subosito/flutter-action@v2
- with:
- channel: stable
- cache: true
-
- - run: flutter pub get
- - run: flutter build apk --release --split-per-abi
-
- - name: Upload artifact
- uses: actions/upload-artifact@v7
- with:
- name: release-android
- path: build/app/outputs/flutter-apk/*.apk
-
release-windows:
name: Release Windows
runs-on: windows-latest
@@ -123,7 +84,7 @@ jobs:
publish:
name: Publish Release
- needs: [release-linux, release-android, release-windows]
+ needs: [release-linux, release-windows]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
@@ -144,5 +105,4 @@ jobs:
generate_release_notes: true
files: |
artifacts/*.tar.gz
- artifacts/*.apk
artifacts/*.zip
diff --git a/.github/workflows/sync-pm3.yml b/.github/workflows/sync-pm3.yml
deleted file mode 100644
index c9639bb..0000000
--- a/.github/workflows/sync-pm3.yml
+++ /dev/null
@@ -1,355 +0,0 @@
-name: Sync and build Proxmark3 for PM3 GUI
-
-# ─────────────────────────────────────────────────────────────────────────────
-# Triggers
-# ─────────────────────────────────────────────────────────────────────────────
-on:
- workflow_dispatch:
- inputs:
- pm3_repo:
- description: 'Git URL of Proxmark3 upstream repo'
- required: false
- default: 'https://github.com/RfidResearchGroup/proxmark3.git'
- pm3_branch:
- description: 'Branch to checkout from PM3 repo'
- required: false
- default: 'master'
- ndk_version:
- description: 'Android NDK version (e.g. r25b)'
- required: false
- default: 'r25b'
- build_firmware:
- description: 'Build firmware images (RDV4 / Generic-512K / Generic-256K)'
- required: false
- type: boolean
- default: true
- # Android builds removed from this workflow
- publish_release:
- description: 'Publish a GitHub release after build'
- required: false
- type: boolean
- default: true
- push:
- branches: [main, master]
-
-# ─────────────────────────────────────────────────────────────────────────────
-# Permissions & concurrency
-# ─────────────────────────────────────────────────────────────────────────────
-permissions:
- contents: read
-
-concurrency:
- group: sync-pm3-${{ github.ref }}
- cancel-in-progress: true
-
-# ─────────────────────────────────────────────────────────────────────────────
-# Shared environment
-# ─────────────────────────────────────────────────────────────────────────────
-env:
- PM3_REPO: ${{ github.event.inputs.pm3_repo || 'https://github.com/RfidResearchGroup/proxmark3.git' }}
- PM3_BRANCH: ${{ github.event.inputs.pm3_branch || 'master' }}
- NDK_VERSION: ${{ github.event.inputs.ndk_version || 'r25b' }}
-
-# ═════════════════════════════════════════════════════════════════════════════
-jobs:
-
-# ─────────────────────────────────────────────────────────────────────────────
-# 1. Native client build (Linux · macOS · Windows/MINGW64)
-#
-# The proxmark3 client binary is platform-agnostic; PLATFORM only affects
-# firmware. We build with SKIPQT=1 so Qt is not required on the runner.
-# ─────────────────────────────────────────────────────────────────────────────
- build-client:
- name: Build client (${{ matrix.os }})
- runs-on: ${{ matrix.os }}
- strategy:
- fail-fast: false
- matrix:
- os: [ubuntu-latest, macos-latest, windows-latest]
-
- steps:
- - name: Checkout pm3gui
- uses: actions/checkout@v6
- with:
- submodules: false
-
- - name: Set build metadata
- id: meta
- shell: bash
- run: |
- echo "short_sha=$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_OUTPUT
- echo "timestamp=$(date -u +%Y%m%dT%H%M%SZ)" >> $GITHUB_OUTPUT
-
- - name: Clone Proxmark3
- shell: bash
- run: git clone --depth=1 --branch "${PM3_BRANCH}" "${PM3_REPO}" proxmark3
-
- # ── Linux ──────────────────────────────────────────────────────────────
- - name: Install dependencies (Linux)
- if: matrix.os == 'ubuntu-latest'
- run: |
- sudo apt-get update -qq
- sudo apt-get install -y \
- build-essential pkg-config git \
- libusb-1.0-0-dev libreadline-dev libncurses5-dev \
- libssl-dev libbz2-dev liblz4-dev zlib1g-dev \
- liblua5.4-dev libpython3-dev libgd-dev
-
- - name: Build client (Linux)
- if: matrix.os == 'ubuntu-latest'
- run: |
- cd proxmark3
- make client SKIPQT=1 -j"$(nproc)"
-
- # ── macOS ──────────────────────────────────────────────────────────────
- - name: Install dependencies (macOS)
- if: matrix.os == 'macos-latest'
- run: |
- brew update || true
- brew install pkg-config libusb readline openssl bzip2 lz4 lua gd
- continue-on-error: false
-
- - name: Build client (macOS)
- if: matrix.os == 'macos-latest'
- run: |
- cd proxmark3
- make client SKIPQT=1 -j"$(sysctl -n hw.ncpu)"
-
- # ── Windows / Official ProxSpace toolchain ────────────────────────────
- # Follow official proxmark3 windows workflow and toolchain.
- - name: Cache MSYS2 packages
- if: matrix.os == 'windows-latest'
- uses: actions/cache@v5
- with:
- path: C:\msys64\mingw64
- key: ${{ runner.os }}-msys2-mingw64-${{ hashFiles('.github/workflows/sync-pm3.yml') }}
- restore-keys: |
- ${{ runner.os }}-msys2-mingw64-
-
- - name: ProxSpace download (Windows)
- if: matrix.os == 'windows-latest'
- shell: pwsh
- run: Invoke-WebRequest "https://github.com/Gator96100/ProxSpace/archive/master.zip" -OutFile "C:\proxspace.zip"
-
- - name: ProxSpace extract (Windows)
- if: matrix.os == 'windows-latest'
- shell: pwsh
- run: Expand-Archive -LiteralPath "C:\proxspace.zip" -DestinationPath "C:\"
-
- - name: ProxSpace cleanup and rename (Windows)
- if: matrix.os == 'windows-latest'
- shell: pwsh
- run: |
- Remove-Item "C:\proxspace.zip" -Force -ErrorAction SilentlyContinue
-
- $src = Get-ChildItem -Path "C:\" -Directory | Where-Object { $_.Name -like "ProxSpace-*" } | Select-Object -First 1
- if (-not $src) {
- throw "ProxSpace extraction failed: no ProxSpace-* directory found in C:\"
- }
-
- if (Test-Path "C:\ProxSpace") {
- Remove-Item "C:\ProxSpace" -Recurse -Force
- }
-
- Rename-Item -Path $src.FullName -NewName "ProxSpace"
-
- if (-not (Test-Path "C:\ProxSpace\runme64.bat")) {
- throw "ProxSpace setup failed: C:\ProxSpace\runme64.bat is missing."
- }
-
- - name: ProxSpace initial startup (Windows)
- if: matrix.os == 'windows-latest'
- shell: pwsh
- working-directory: C:\ProxSpace
- run: |
- if (-not (Test-Path ".\runme64.bat")) {
- throw "runme64.bat not found in C:\ProxSpace"
- }
-
- $maxAttempts = 3
- $delay = 20
- for ($i = 1; $i -le $maxAttempts; $i++) {
- Write-Host "Attempt $i..."
- ./runme64.bat -c "exit"
- if ($LASTEXITCODE -eq 0) { exit 0 }
- if ($i -lt $maxAttempts) { Start-Sleep -Seconds $delay }
- }
- exit 1
-
- - name: Build client (Windows via ProxSpace)
- if: matrix.os == 'windows-latest'
- shell: pwsh
- run: |
- $ws = $env:GITHUB_WORKSPACE -replace '\\','/'
- $pm3Dir = "$ws/proxmark3" -replace '\\','/'
-
- # Change to ProxSpace directory first, then execute the build command
- Set-Location "C:\ProxSpace"
- & ".\runme64.bat" -c "cd '$pm3Dir' && make client SKIPQT=1 -j2"
-
- # ── Upload ─────────────────────────────────────────────────────────────
- - name: Upload client artifact (Linux / macOS)
- if: matrix.os != 'windows-latest'
- uses: actions/upload-artifact@v7
- with:
- name: pm3-client-${{ matrix.os }}-${{ steps.meta.outputs.short_sha }}
- path: |
- proxmark3/client/proxmark3
- proxmark3/pm3
-
- - name: Upload client artifact (Windows)
- if: matrix.os == 'windows-latest'
- uses: actions/upload-artifact@v7
- with:
- name: pm3-client-windows-proxspace-${{ steps.meta.outputs.short_sha }}
- path: |
- proxmark3/client/proxmark3.exe
- proxmark3/pm3.exe
-
-# ─────────────────────────────────────────────────────────────────────────────
-# 2. Firmware build (Ubuntu · arm-none-eabi toolchain)
-#
-# Three platform variants are built in a matrix:
-# rdv4 – PM3RDV4 (default, full-featured, 512 KB flash)
-# generic-512k– PM3GENERIC for 512 KB flash clone devices
-# generic-256k– PM3GENERIC with PLATFORM_SIZE=256 and reduced feature
-# set to fit in 256 KB flash (see Makefile.platform.sample)
-#
-# Skipped when build_firmware input is explicitly set to 'false'.
-# ─────────────────────────────────────────────────────────────────────────────
- build-firmware:
- name: Build firmware (${{ matrix.variant.name }})
- runs-on: ubuntu-latest
- if: github.event.inputs.build_firmware != 'false'
- strategy:
- fail-fast: false
- matrix:
- variant:
- # ── RDV4: default hardware, all features, 512 KB flash ────────────
- - name: rdv4
- make_args: PLATFORM=PM3RDV4
-
- # ── Generic 512 KB: clone/generic hardware with full flash ─────────
- - name: generic-512k
- make_args: PLATFORM=PM3GENERIC
-
- # ── Generic 256 KB: stripped build for 256 KB flash devices ────────
- # See: doc/md/Use_of_Proxmark/4_Advanced-compilation-parameters.md
- - name: generic-256k
- make_args: >-
- PLATFORM=PM3GENERIC
- PLATFORM_SIZE=256
- SKIP_LF=1
- SKIP_HITAG=1
- SKIP_EM4x50=1
- SKIP_EM4x70=1
- SKIP_ZX8211=1
- SKIP_HF=1
- SKIP_ISO15693=1
- SKIP_LEGICRF=1
-
- steps:
- - name: Checkout pm3gui
- uses: actions/checkout@v6
- with:
- submodules: false
-
- - name: Set build metadata
- id: meta
- shell: bash
- run: |
- echo "short_sha=$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_OUTPUT
- echo "timestamp=$(date -u +%Y%m%dT%H%M%SZ)" >> $GITHUB_OUTPUT
-
- - name: Clone Proxmark3
- shell: bash
- run: git clone --depth=1 --branch "${PM3_BRANCH}" "${PM3_REPO}" proxmark3
-
- - name: Install firmware build dependencies
- run: |
- sudo apt-get update -qq
- sudo apt-get install -y \
- build-essential pkg-config git \
- gcc-arm-none-eabi libnewlib-dev \
- libusb-1.0-0-dev libreadline-dev libncurses5-dev \
- libssl-dev libbz2-dev liblz4-dev zlib1g-dev \
- liblua5.4-dev libpython3-dev libgd-dev
-
- - name: Build firmware (${{ matrix.variant.name }})
- run: |
- cd proxmark3
- make fullimage SKIPQT=1 ${{ matrix.variant.make_args }} -j"$(nproc)"
-
- - name: Upload firmware artifact (${{ matrix.variant.name }})
- uses: actions/upload-artifact@v7
- with:
- name: pm3-firmware-${{ matrix.variant.name }}-${{ steps.meta.outputs.short_sha }}
- path: |
- proxmark3/armsrc/obj/fullimage.elf
- proxmark3/bootrom/obj/bootrom.elf
-
-# Android cross-compile removed from this workflow by request
-
-# ─────────────────────────────────────────────────────────────────────────────
-# 4. Release – collect all artifacts and publish a GitHub release
-#
-# Runs after client + firmware build jobs. Proceeds when firmware was
-# intentionally skipped (result == 'skipped'), but aborts if any required
-# job failed or was cancelled.
-# ─────────────────────────────────────────────────────────────────────────────
- release:
- name: Publish release
- runs-on: ubuntu-latest
- needs: [build-client, build-firmware]
- if: |
- !cancelled() &&
- needs.build-client.result == 'success' &&
- (needs.build-firmware.result == 'success' || needs.build-firmware.result == 'skipped') &&
- (
- (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')) ||
- (github.event_name == 'workflow_dispatch' && github.event.inputs.publish_release != 'false')
- )
- permissions:
- contents: write
-
- steps:
- - name: Set release metadata
- id: meta
- shell: bash
- run: echo "short_sha=$(echo ${GITHUB_SHA} | cut -c1-8)" >> $GITHUB_OUTPUT
-
- - name: Download all artifacts
- uses: actions/download-artifact@v8
- with:
- merge-multiple: true
- path: artifacts
-
- - name: List artifacts
- shell: bash
- run: ls -la artifacts/ || true
-
- - name: Create Release
- uses: softprops/action-gh-release@v3.0.0
- with:
- tag_name: pm3-client-${{ steps.meta.outputs.short_sha }}
- name: "pm3-client ${{ steps.meta.outputs.short_sha }}"
- body: |
- ## Proxmark3 builds — ${{ steps.meta.outputs.short_sha }}
-
- ### Client binaries (host PC)
- | File | Platform |
- |------|----------|
- | `pm3-client-ubuntu-latest-*` | Linux x86_64 |
- | `pm3-client-macos-latest-*` | macOS (Intel/Apple Silicon) |
- | `pm3-client-windows-proxspace-*` | Windows 64-bit (official ProxSpace toolchain) |
-
- ### Firmware images (flash to device)
- | File | Hardware target |
- |------|----------------|
- | `pm3-firmware-rdv4-*` | Proxmark3 RDV4 (512 KB, default) |
- | `pm3-firmware-generic-512k-*` | Generic Proxmark3 clone (512 KB) |
- | `pm3-firmware-generic-256k-*` | Generic Proxmark3 clone (256 KB) |
-
- ### Android client binaries
- Android builds removed from this workflow; Android artifacts are no longer produced here.
- files: artifacts/**
diff --git a/README.md b/README.md
index c8ae186..d5101d1 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
@@ -22,7 +22,7 @@ PM3 GUI 是一个面向 Proxmark3 的跨平台图形化客户端,聚焦 RFID/N
## Core Features
-- **跨平台支持**:Android、Linux、Windows。
+- **桌面平台支持**:Linux、Windows。
- **CLI Wrapper 架构**:通过进程管道驱动 `pm3`/`proxmark3`,自动继承上游命令能力。
- **终端模式**:支持完整命令输入、历史回溯与输出展示。
- **Dump/Key 文件管理**:自动扫描、识别、分组与归档。
@@ -31,17 +31,27 @@ PM3 GUI 是一个面向 Proxmark3 的跨平台图形化客户端,聚焦 RFID/N
## Architecture Overview
```text
-Flutter UI (pages + components)
- ├─ State layer (Provider)
- ├─ Parser layer (.eml/.bin/.json/.dic)
- └─ Service layer
- ├─ Pm3Process (stdin/stdout bridge)
- ├─ FileCollector / FileCache
- └─ DumpConverter / Command catalog
- │
- └─ proxmark3 CLI process
+Flutter Desktop UI / Feature
+ │
+ ▼
+ Pm3Controller
+ │
+ ▼
+ Pm3Backend
+ │
+ ▼
+ DesktopCliBackend
+ │
+ ▼
+ Pm3Process (internal adapter)
+ │
+ ▼
+ official proxmark3 client
```
+Widget 和 Feature 仅通过 `Pm3Controller` 使用结构化命令、结果和事件;
+`dart:io Process` 被限制在 Desktop CLI Backend 内部。
+
## Installation & Quick Start
### Prerequisites
@@ -64,7 +74,6 @@ flutter run -d linux
```bash
flutter build linux
flutter build windows
-flutter build apk --split-per-abi
```
## Supported File Formats
@@ -81,11 +90,13 @@ flutter build apk --split-per-abi
```text
lib/
-├─ models/ # 数据模型
-├─ parsers/ # dump/key 解析器
-├─ services/ # PM3 进程、文件、命令与转换服务
+├─ core/pm3/ # 后端契约、命令、事件、结果与 Controller
+├─ backend/ # Desktop CLI 实现与 Mock Backend
+├─ models/ # 现有数据模型(逐步迁移)
+├─ parsers/ # 现有 dump/key 解析器(逐步迁移)
+├─ services/ # 文件、命令与转换服务
├─ state/ # Provider 状态管理
-└─ ui/ # 页面与组件
+└─ ui/ # 页面与组件(逐步迁移到 features)
docs/ # 规格说明、开发任务、命令映射
.github/workflows/ # CI/CD 工作流
@@ -103,9 +114,11 @@ flutter test
CI/CD(GitHub Actions)包含:
-- `build.yml`:主分支/PR 多平台构建与静态检查。
-- `release.yml`:标签触发构建并发布 Release 资产。
-- `sync-pm3.yml`:同步并构建上游 Proxmark3 客户端/固件。
+- `build.yml`:主分支/PR 的 Windows、Linux 桌面构建与静态检查,也可手动触发。
+- `release.yml`:版本标签触发 Windows、Linux 构建并发布 Release 资产。
+
+CI 不构建或发布 Proxmark3 Client/固件。Proxmark3 由上游项目维护,GUI
+构建与 PM3 构建保持独立,避免重复消耗 CI 资源和产生来源不明确的二进制文件。
## Contributing
@@ -119,7 +132,7 @@ CI/CD(GitHub Actions)包含:
- 完善更多 PM3 子命令页面覆盖。
- 增强 dump 差异分析与异常提示。
-- 提升 Windows/Android 设备连接稳定性。
+- 提升 Windows/Linux 设备连接与进程生命周期稳定性。
## License
diff --git a/lib/backend/desktop_cli/desktop_cli_backend.dart b/lib/backend/desktop_cli/desktop_cli_backend.dart
new file mode 100644
index 0000000..e9b4d36
--- /dev/null
+++ b/lib/backend/desktop_cli/desktop_cli_backend.dart
@@ -0,0 +1,117 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:pm3gui/core/pm3/pm3_backend.dart';
+import 'package:pm3gui/core/pm3/pm3_command.dart';
+import 'package:pm3gui/core/pm3/pm3_connection.dart';
+import 'package:pm3gui/core/pm3/pm3_event.dart';
+import 'package:pm3gui/core/pm3/pm3_result.dart';
+
+import 'pm3_process.dart';
+
+/// Desktop backend backed by the official Proxmark3 CLI process.
+final class DesktopCliBackend implements Pm3Backend {
+ DesktopCliBackend({Pm3Process? process})
+ : _process = process ?? Pm3Process() {
+ _outputSubscription = _process.outputStream.listen((line) {
+ _events.add(Pm3OutputEvent(DateTime.now(), line));
+ });
+ _stateSubscription = _process.stateStream.listen((state) {
+ _events.add(Pm3StateChangedEvent(DateTime.now(), _mapState(state)));
+ });
+ }
+
+ final Pm3Process _process;
+ final StreamController _events =
+ StreamController.broadcast(sync: true);
+ late final StreamSubscription _outputSubscription;
+ late final StreamSubscription _stateSubscription;
+ Pm3ConnectionConfig? _config;
+
+ /// Selects a sensible external-client default without leaking platform
+ /// checks into application state or features.
+ static String detectExecutable() {
+ final candidates = Platform.isWindows
+ ? const [r'C:\proxmark3\client\proxmark3.exe', 'proxmark3.exe']
+ : const ['/usr/local/bin/proxmark3', '/usr/bin/proxmark3'];
+ for (final candidate in candidates) {
+ if (FileSystemEntity.isFileSync(candidate)) return candidate;
+ }
+ try {
+ final lookup = Process.runSync(
+ Platform.isWindows ? 'where' : 'which',
+ [Platform.isWindows ? 'proxmark3.exe' : 'proxmark3'],
+ );
+ if (lookup.exitCode == 0) {
+ final path =
+ (lookup.stdout as String).trim().split(RegExp(r'[\r\n]+')).first;
+ if (path.isNotEmpty) return path;
+ }
+ } on ProcessException {
+ // The settings page lets the user select an explicit client path.
+ }
+ return Platform.isWindows ? 'proxmark3.exe' : 'proxmark3';
+ }
+
+ @override
+ Stream get events => _events.stream;
+ @override
+ Pm3ConnectionState get state => _mapState(_process.state);
+ @override
+ String get version => _process.version;
+ @override
+ String get lastError => _process.lastError;
+
+ @override
+ Future connect(Pm3ConnectionConfig config) async {
+ _config = config;
+ await _process.connect(
+ config.executable,
+ config.port,
+ arguments: config.arguments,
+ workingDirectory: config.workingDirectory,
+ );
+ }
+
+ @override
+ Future disconnect() => _process.disconnect();
+
+ @override
+ Future execute(Pm3Command command, {Duration? timeout}) async {
+ if (_config == null || state != Pm3ConnectionState.connected) {
+ throw StateError('PM3 is not connected');
+ }
+ final startedAt = DateTime.now();
+ final output = await _process.sendCommandAndWait(
+ command.executable,
+ timeout: timeout ?? const Duration(seconds: 10),
+ );
+ return Pm3Result(
+ command: command,
+ output: output,
+ startedAt: startedAt,
+ finishedAt: DateTime.now(),
+ );
+ }
+
+ @override
+ Future cancel() async {
+ if (state == Pm3ConnectionState.connected) {
+ await _process.sendCommand('break');
+ }
+ }
+
+ @override
+ void dispose() {
+ _outputSubscription.cancel();
+ _stateSubscription.cancel();
+ _process.dispose();
+ _events.close();
+ }
+
+ static Pm3ConnectionState _mapState(Pm3ProcessState state) => switch (state) {
+ Pm3ProcessState.disconnected => Pm3ConnectionState.disconnected,
+ Pm3ProcessState.connecting => Pm3ConnectionState.connecting,
+ Pm3ProcessState.connected => Pm3ConnectionState.connected,
+ };
+}
diff --git a/lib/services/pm3_process.dart b/lib/backend/desktop_cli/pm3_process.dart
similarity index 82%
rename from lib/services/pm3_process.dart
rename to lib/backend/desktop_cli/pm3_process.dart
index da50892..48671d9 100644
--- a/lib/services/pm3_process.dart
+++ b/lib/backend/desktop_cli/pm3_process.dart
@@ -11,7 +11,7 @@ import 'dart:convert';
import 'dart:io';
/// PM3 客户端进程的连接状态
-enum Pm3State {
+enum Pm3ProcessState {
disconnected, // 未连接
connecting, // 正在连接
connected // 已连接
@@ -20,7 +20,7 @@ enum Pm3State {
/// 包装 pm3 命令行进程以进行通信
class Pm3Process {
Process? _process; // PM3 进程实例
- Pm3State _state = Pm3State.disconnected; // 当前连接状态
+ Pm3ProcessState _state = Pm3ProcessState.disconnected; // 当前连接状态
String _version = ''; // PM3 版本信息
String _lastError = ''; // 最后一次错误信息
DateTime? _lastConnectAttempt; // 最后一次连接尝试时间
@@ -29,18 +29,18 @@ class Pm3Process {
final _outputController = StreamController.broadcast(sync: true);
/// 状态变化的流
- final _stateController = StreamController.broadcast(sync: true);
+ final _stateController =
+ StreamController.broadcast(sync: true);
/// 用于响应匹配的累积输出缓冲区
final _responseBuffer = StringBuffer();
-
Stream get outputStream => _outputController.stream;
- Stream get stateStream => _stateController.stream;
- Pm3State get state => _state;
+ Stream get stateStream => _stateController.stream;
+ Pm3ProcessState get state => _state;
String get version => _version;
String get lastError => _lastError;
- bool get isConnected => _state == Pm3State.connected;
+ bool get isConnected => _state == Pm3ProcessState.connected;
/// 连接尝试之间的最小间隔(防止重试风暴)
static const _connectCooldown = Duration(seconds: 3);
@@ -48,10 +48,8 @@ class Pm3Process {
/// 解析 pm3 可执行文件路径
///
/// 按以下顺序尝试:
- /// 1. 如果用户提供的路径作为绝对路径存在
- /// 2. 用户提供的路径相对于常见 PM3 根目录
- /// 3. 系统 PATH 上的 'proxmark3'
- /// 4. 基于应用自身目录的猜测位置
+ /// 1. 用户提供的绝对或相对路径
+ /// 2. Windows/Linux 系统 PATH
///
/// 返回记录 (resolvedPath, workingDirectory?) 或 null
static (String, String?)? resolvePm3Path(String userPath) {
@@ -62,30 +60,15 @@ class Pm3Process {
return (file.absolute.path, file.absolute.parent.path);
}
- // 2. 常见位置
- final candidates = [
- '/root/dev/proxmark3/pm3',
- '/root/dev/proxmark3/client/proxmark3',
- // Local vendored proxmark3 inside pm3gui (third_party)
- '${Directory.current.path}/third_party/proxmark3/pm3',
- '${Directory.current.path}/third_party/proxmark3/client/proxmark3',
- // Also support relative paths from package root (useful in development)
- './third_party/proxmark3/pm3',
- './third_party/proxmark3/client/proxmark3',
- '/usr/local/bin/proxmark3',
- '/usr/bin/proxmark3',
- ];
- for (final c in candidates) {
- if (File(c).existsSync()) {
- return (c, File(c).parent.path);
- }
- }
-
- // 3. 通过 `which` 检查 PATH
+ // 2. 通过平台命令检查 PATH
try {
- final result = Process.runSync('which', [userPath]);
+ final result = Process.runSync(
+ Platform.isWindows ? 'where' : 'which',
+ [userPath],
+ );
if (result.exitCode == 0) {
- final p = (result.stdout as String).trim();
+ final p =
+ (result.stdout as String).trim().split(RegExp(r'[\r\n]+')).first;
if (p.isNotEmpty && File(p).existsSync()) {
return (p, null);
}
@@ -99,7 +82,12 @@ class Pm3Process {
///
/// [pm3Path] - pm3 可执行文件路径(例如 "./pm3" 或完整路径)
/// [port] - 串口(例如 "/dev/ttyACM0", "COM3")
- Future connect(String pm3Path, String port) async {
+ Future connect(
+ String pm3Path,
+ String port, {
+ List arguments = const [],
+ String? workingDirectory,
+ }) async {
// 冷却期 — 防止重试风暴
if (_lastConnectAttempt != null) {
final elapsed = DateTime.now().difference(_lastConnectAttempt!);
@@ -113,26 +101,25 @@ class Pm3Process {
_lastConnectAttempt = DateTime.now();
_lastError = '';
- if (_state != Pm3State.disconnected) {
+ if (_state != Pm3ProcessState.disconnected) {
await disconnect();
}
- _setState(Pm3State.connecting);
+ _setState(Pm3ProcessState.connecting);
try {
// 解析实际可执行文件路径
final resolved = resolvePm3Path(pm3Path);
if (resolved == null) {
_lastError = '找不到 PM3 程序: $pm3Path\n'
- '请在连接页面设置正确的 PM3 程序路径,例如:\n'
- ' /root/dev/proxmark3/pm3\n'
- ' /usr/local/bin/proxmark3';
+ '请在连接页面设置 proxmark3.exe 或 proxmark3 的正确路径';
_outputController.add('[错误] $_lastError');
- _setState(Pm3State.disconnected);
+ _setState(Pm3ProcessState.disconnected);
return false;
}
- final (execPath, workDir) = resolved;
+ final (execPath, resolvedWorkDir) = resolved;
+ final workDir = workingDirectory ?? resolvedWorkDir;
_outputController.add('[使用 PM3: $execPath]');
if (workDir != null) {
_outputController.add('[工作目录: $workDir]');
@@ -141,7 +128,7 @@ class Pm3Process {
// 启动 pm3,使用 -p port -f(实时输出的刷新模式)
_process = await Process.start(
execPath,
- ['-p', port, '-f'],
+ [...arguments, '-p', port, '-f'],
workingDirectory: workDir,
mode: ProcessStartMode.normal,
);
@@ -167,7 +154,7 @@ class Pm3Process {
if (!connected && _isConnectionPrompt(line)) {
connected = true;
_extractVersion(line);
- _setState(Pm3State.connected);
+ _setState(Pm3ProcessState.connected);
if (!completer.isCompleted) completer.complete(true);
}
@@ -187,8 +174,8 @@ class Pm3Process {
// 处理进程退出
_process!.exitCode.then((code) {
_outputController.add('[PM3 进程退出, code=$code]');
- if (_state != Pm3State.disconnected) {
- _setState(Pm3State.disconnected);
+ if (_state != Pm3ProcessState.disconnected) {
+ _setState(Pm3ProcessState.disconnected);
}
_process = null;
});
@@ -210,12 +197,12 @@ class Pm3Process {
'路径: $pm3Path\n'
'请检查路径是否正确,程序是否已编译';
_outputController.add('[错误] $_lastError');
- _setState(Pm3State.disconnected);
+ _setState(Pm3ProcessState.disconnected);
return false;
} catch (e) {
_lastError = '连接失败: $e';
_outputController.add('[错误] $_lastError');
- _setState(Pm3State.disconnected);
+ _setState(Pm3ProcessState.disconnected);
return false;
}
}
@@ -248,7 +235,7 @@ class Pm3Process {
/// 向交互式会话发送命令
Future sendCommand(String command) async {
- if (_process == null || _state != Pm3State.connected) {
+ if (_process == null || _state != Pm3ProcessState.connected) {
_outputController.add('[未连接]');
return;
}
@@ -266,11 +253,12 @@ class Pm3Process {
Future sendCommandAndWait(String command,
{Duration timeout = const Duration(seconds: 10),
RegExp? terminator}) async {
- if (_process == null || _state != Pm3State.connected) {
+ if (_process == null || _state != Pm3ProcessState.connected) {
return '[未连接]';
}
_responseBuffer.clear();
+ _outputController.add('[pm3] $command');
_process!.stdin.writeln(command);
await _process!.stdin.flush();
@@ -312,7 +300,7 @@ class Pm3Process {
} catch (_) {}
_process = null;
}
- _setState(Pm3State.disconnected);
+ _setState(Pm3ProcessState.disconnected);
}
/// 释放资源
@@ -370,9 +358,8 @@ class Pm3Process {
}
/// 设置状态并通知监听器
- void _setState(Pm3State newState) {
+ void _setState(Pm3ProcessState newState) {
_state = newState;
_stateController.add(newState);
}
-
}
diff --git a/lib/backend/mock/mock_pm3_backend.dart b/lib/backend/mock/mock_pm3_backend.dart
new file mode 100644
index 0000000..01e55d5
--- /dev/null
+++ b/lib/backend/mock/mock_pm3_backend.dart
@@ -0,0 +1,66 @@
+import 'dart:async';
+
+import 'package:pm3gui/core/pm3/pm3_backend.dart';
+import 'package:pm3gui/core/pm3/pm3_command.dart';
+import 'package:pm3gui/core/pm3/pm3_connection.dart';
+import 'package:pm3gui/core/pm3/pm3_event.dart';
+import 'package:pm3gui/core/pm3/pm3_result.dart';
+
+/// Deterministic backend for feature development and tests without hardware.
+final class MockPm3Backend implements Pm3Backend {
+ MockPm3Backend({this.responses = const {}});
+
+ final Map responses;
+ final StreamController _events =
+ StreamController.broadcast(sync: true);
+ Pm3ConnectionState _state = Pm3ConnectionState.disconnected;
+
+ @override
+ Stream get events => _events.stream;
+ @override
+ Pm3ConnectionState get state => _state;
+ @override
+ String get version => 'mock';
+ @override
+ String get lastError => '';
+
+ @override
+ Future connect(Pm3ConnectionConfig config) async {
+ _setState(Pm3ConnectionState.connecting);
+ _setState(Pm3ConnectionState.connected);
+ }
+
+ @override
+ Future execute(Pm3Command command, {Duration? timeout}) async {
+ if (_state != Pm3ConnectionState.connected) {
+ throw StateError('PM3 is not connected');
+ }
+ final startedAt = DateTime.now();
+ final output = responses[command.id] ?? '';
+ for (final line in output.split('\n').where((line) => line.isNotEmpty)) {
+ _events.add(Pm3OutputEvent(DateTime.now(), line));
+ }
+ return Pm3Result(
+ command: command,
+ output: output,
+ startedAt: startedAt,
+ finishedAt: DateTime.now(),
+ );
+ }
+
+ @override
+ Future cancel() async {}
+
+ @override
+ Future disconnect() async {
+ _setState(Pm3ConnectionState.disconnected);
+ }
+
+ void _setState(Pm3ConnectionState state) {
+ _state = state;
+ _events.add(Pm3StateChangedEvent(DateTime.now(), state));
+ }
+
+ @override
+ void dispose() => _events.close();
+}
diff --git a/lib/core/pm3/pm3_backend.dart b/lib/core/pm3/pm3_backend.dart
new file mode 100644
index 0000000..723f2ca
--- /dev/null
+++ b/lib/core/pm3/pm3_backend.dart
@@ -0,0 +1,17 @@
+import 'pm3_command.dart';
+import 'pm3_connection.dart';
+import 'pm3_event.dart';
+import 'pm3_result.dart';
+
+abstract interface class Pm3Backend {
+ Stream get events;
+ Pm3ConnectionState get state;
+ String get version;
+ String get lastError;
+
+ Future connect(Pm3ConnectionConfig config);
+ Future disconnect();
+ Future execute(Pm3Command command, {Duration? timeout});
+ Future cancel();
+ void dispose();
+}
diff --git a/lib/core/pm3/pm3_command.dart b/lib/core/pm3/pm3_command.dart
new file mode 100644
index 0000000..aa96416
--- /dev/null
+++ b/lib/core/pm3/pm3_command.dart
@@ -0,0 +1,7 @@
+/// A typed command understood by the official Proxmark3 client.
+final class Pm3Command {
+ const Pm3Command({required this.id, required this.executable});
+
+ final String id;
+ final String executable;
+}
diff --git a/lib/core/pm3/pm3_connection.dart b/lib/core/pm3/pm3_connection.dart
new file mode 100644
index 0000000..a83f35e
--- /dev/null
+++ b/lib/core/pm3/pm3_connection.dart
@@ -0,0 +1,17 @@
+/// Connection lifecycle exposed by every PM3 backend.
+enum Pm3ConnectionState { disconnected, connecting, connected }
+
+/// Platform-neutral configuration for launching the PM3 client.
+final class Pm3ConnectionConfig {
+ const Pm3ConnectionConfig({
+ required this.executable,
+ required this.port,
+ this.arguments = const [],
+ this.workingDirectory,
+ });
+
+ final String executable;
+ final String port;
+ final List arguments;
+ final String? workingDirectory;
+}
diff --git a/lib/core/pm3/pm3_controller.dart b/lib/core/pm3/pm3_controller.dart
new file mode 100644
index 0000000..e62d1ba
--- /dev/null
+++ b/lib/core/pm3/pm3_controller.dart
@@ -0,0 +1,43 @@
+import 'pm3_backend.dart';
+import 'pm3_command.dart';
+import 'pm3_connection.dart';
+import 'pm3_event.dart';
+import 'pm3_result.dart';
+
+/// The only PM3 entry point exposed to application and feature layers.
+final class Pm3Controller {
+ Pm3Controller(this._backend);
+
+ final Pm3Backend _backend;
+
+ Stream get events => _backend.events;
+ Stream get outputLines => events
+ .where((event) => event is Pm3OutputEvent)
+ .cast()
+ .map((event) => event.line);
+ Stream get stateChanges => events
+ .where((event) => event is Pm3StateChangedEvent)
+ .cast()
+ .map((event) => event.state);
+ Pm3ConnectionState get state => _backend.state;
+ String get version => _backend.version;
+ String get lastError => _backend.lastError;
+ bool get isConnected => state == Pm3ConnectionState.connected;
+
+ Future connect(Pm3ConnectionConfig config) async {
+ await _backend.connect(config);
+ return isConnected;
+ }
+
+ Future disconnect() => _backend.disconnect();
+
+ Future execute(Pm3Command command, {Duration? timeout}) =>
+ _backend.execute(command, timeout: timeout);
+
+ Future send(String command) async {
+ await execute(Pm3Command(id: 'terminal.raw', executable: command));
+ }
+
+ Future cancel() => _backend.cancel();
+ void dispose() => _backend.dispose();
+}
diff --git a/lib/core/pm3/pm3_event.dart b/lib/core/pm3/pm3_event.dart
new file mode 100644
index 0000000..c076698
--- /dev/null
+++ b/lib/core/pm3/pm3_event.dart
@@ -0,0 +1,16 @@
+import 'pm3_connection.dart';
+
+sealed class Pm3Event {
+ const Pm3Event(this.timestamp);
+ final DateTime timestamp;
+}
+
+final class Pm3OutputEvent extends Pm3Event {
+ const Pm3OutputEvent(super.timestamp, this.line);
+ final String line;
+}
+
+final class Pm3StateChangedEvent extends Pm3Event {
+ const Pm3StateChangedEvent(super.timestamp, this.state);
+ final Pm3ConnectionState state;
+}
diff --git a/lib/core/pm3/pm3_result.dart b/lib/core/pm3/pm3_result.dart
new file mode 100644
index 0000000..8e442d1
--- /dev/null
+++ b/lib/core/pm3/pm3_result.dart
@@ -0,0 +1,18 @@
+import 'pm3_command.dart';
+
+/// Completed output from a PM3 command.
+final class Pm3Result {
+ const Pm3Result({
+ required this.command,
+ required this.output,
+ required this.startedAt,
+ required this.finishedAt,
+ });
+
+ final Pm3Command command;
+ final String output;
+ final DateTime startedAt;
+ final DateTime finishedAt;
+
+ Duration get duration => finishedAt.difference(startedAt);
+}
diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart
index 21aff27..db5b014 100644
--- a/lib/state/app_state.dart
+++ b/lib/state/app_state.dart
@@ -2,7 +2,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:pm3gui/models/mifare_card.dart';
import 'package:pm3gui/services/file_collector.dart';
-import 'package:pm3gui/services/pm3_process.dart';
+import 'package:pm3gui/core/pm3/pm3_connection.dart';
import 'package:pm3gui/state/connection_state.dart';
import 'package:pm3gui/state/terminal_state.dart';
import 'package:pm3gui/state/file_state.dart';
@@ -146,7 +146,7 @@ class AppState extends ChangeNotifier {
int get hwFlashTotal => hardwareState.hwFlashTotal;
bool get hwInfoParsed => hardwareState.hwInfoParsed;
- Pm3Process get pm3 => connectionState.pm3;
+ Stream get pm3Output => connectionState.controller.outputLines;
AppState() {
connectionState = ConnectionState();
@@ -158,7 +158,7 @@ class AppState extends ChangeNotifier {
// (via context.select on outputRevision / terminalOutput) will rebuild
// in real-time when TerminalState notifies.
terminalState.addListener(_onTerminalChanged);
- connectionState.pm3.outputStream.listen((line) {
+ connectionState.controller.outputLines.listen((line) {
terminalState.addOutput(line);
if (line.toLowerCase().contains('saved') ||
@@ -167,11 +167,11 @@ class AppState extends ChangeNotifier {
}
});
- connectionState.pm3.stateStream.listen((state) {
- if (state == Pm3State.connected) {
+ connectionState.controller.stateChanges.listen((state) {
+ if (state == Pm3ConnectionState.connected) {
scanForFiles();
_queryHwVersion();
- } else if (state == Pm3State.disconnected) {
+ } else if (state == Pm3ConnectionState.disconnected) {
hardwareState.resetHwInfo();
}
notifyListeners();
@@ -360,11 +360,11 @@ class AppState extends ChangeNotifier {
final buffer = StringBuffer();
StreamSubscription? sub;
- sub = connectionState.pm3.outputStream.listen((line) {
+ sub = connectionState.controller.outputLines.listen((line) {
buffer.writeln(line);
});
- connectionState.pm3.sendCommand('hw version');
+ connectionState.controller.send('hw version');
Future.delayed(const Duration(seconds: 3), () {
sub?.cancel();
diff --git a/lib/state/connection_state.dart b/lib/state/connection_state.dart
index c2cdab3..ce5e7c3 100644
--- a/lib/state/connection_state.dart
+++ b/lib/state/connection_state.dart
@@ -1,35 +1,39 @@
/// 设备连接状态管理
library;
-import 'dart:io';
import 'package:flutter/foundation.dart';
-import 'package:pm3gui/services/pm3_process.dart';
+import 'package:pm3gui/backend/desktop_cli/desktop_cli_backend.dart';
+import 'package:pm3gui/core/pm3/pm3_connection.dart';
+import 'package:pm3gui/core/pm3/pm3_controller.dart';
class ConnectionState extends ChangeNotifier {
- final Pm3Process pm3 = Pm3Process();
+ ConnectionState({Pm3Controller? controller})
+ : controller = controller ?? Pm3Controller(DesktopCliBackend()) {
+ pm3Path = DesktopCliBackend.detectExecutable();
+ }
+
+ final Pm3Controller controller;
String pm3Path = '';
String portName = '';
List availablePorts = [];
- Pm3State get connectionState => pm3.state;
- String get pm3Version => pm3.version;
- bool get isConnected => pm3.state == Pm3State.connected;
- String get lastError => pm3.lastError;
-
- ConnectionState() {
- pm3Path = _detectPm3Path();
- }
+ Pm3ConnectionState get connectionState => controller.state;
+ String get pm3Version => controller.version;
+ bool get isConnected => controller.isConnected;
+ String get lastError => controller.lastError;
Future connect() async {
if (portName.isEmpty) return false;
- final result = await pm3.connect(pm3Path, portName);
- return result;
+ return controller.connect(Pm3ConnectionConfig(
+ executable: pm3Path,
+ port: portName,
+ ));
}
Future disconnect() async {
- await pm3.disconnect();
+ await controller.disconnect();
notifyListeners();
}
@@ -50,31 +54,12 @@ class ConnectionState extends ChangeNotifier {
Future sendCommand(String cmd) async {
if (cmd.trim().isEmpty) return;
- await pm3.sendCommand(cmd);
+ await controller.send(cmd);
}
@override
void dispose() {
- pm3.dispose();
+ controller.dispose();
super.dispose();
}
-
- static String _detectPm3Path() {
- final candidates = [
- '/root/dev/proxmark3/pm3',
- '/usr/local/bin/proxmark3',
- '/usr/bin/proxmark3',
- ];
- for (final c in candidates) {
- if (FileSystemEntity.isFileSync(c)) return c;
- }
- try {
- final r = Process.runSync('which', ['proxmark3']);
- if (r.exitCode == 0) {
- final p = (r.stdout as String).trim();
- if (p.isNotEmpty) return p;
- }
- } catch (_) {}
- return './pm3';
- }
}
diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart
index 83d7b45..453d880 100644
--- a/lib/ui/home_page.dart
+++ b/lib/ui/home_page.dart
@@ -5,8 +5,8 @@ library;
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/services/pm3_process.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';
@@ -134,8 +134,8 @@ class _HomePageState extends State {
final currentPageIndex =
context.select((s) => s.currentPageIndex);
final pm3Version = context.select((s) => s.pm3Version);
- final isConnected = context.select(
- (s) => s.connectionState.connectionState == Pm3State.connected);
+ final isConnected = context.select((s) =>
+ s.connectionState.connectionState == Pm3ConnectionState.connected);
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
diff --git a/lib/ui/pages/connection_page.dart b/lib/ui/pages/connection_page.dart
index 34be8db..e3e3fe8 100644
--- a/lib/ui/pages/connection_page.dart
+++ b/lib/ui/pages/connection_page.dart
@@ -2,10 +2,11 @@
library;
import 'dart:io';
+import 'dart:ffi';
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/services/pm3_process.dart';
import 'package:pm3gui/services/file_collector.dart';
import 'package:pm3gui/ui/theme.dart';
@@ -67,10 +68,10 @@ class _ConnectionPageState extends State {
@override
Widget build(BuildContext context) {
final appState = context.read();
- final isConnected = context.select(
- (s) => s.connectionState.connectionState == Pm3State.connected);
- final isConnecting = context.select(
- (s) => s.connectionState.connectionState == Pm3State.connecting);
+ final isConnected = context.select((s) =>
+ s.connectionState.connectionState == Pm3ConnectionState.connected);
+ final isConnecting = context.select((s) =>
+ s.connectionState.connectionState == Pm3ConnectionState.connecting);
final pm3Path = context.select((s) => s.pm3Path);
final availablePorts =
context.select>((s) => s.availablePorts);
@@ -778,10 +779,6 @@ class _ConnectionPageState extends State {
}
static String _getArch() {
- try {
- final r = Process.runSync('uname', ['-m']);
- if (r.exitCode == 0) return (r.stdout as String).trim();
- } catch (_) {}
- return 'unknown';
+ return Abi.current().toString().split('_').last;
}
}
diff --git a/lib/ui/pages/data_page.dart b/lib/ui/pages/data_page.dart
index 3f6ee70..3182a7e 100644
--- a/lib/ui/pages/data_page.dart
+++ b/lib/ui/pages/data_page.dart
@@ -51,12 +51,7 @@ class _DataPageState extends State
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context
- .read()
- .connectionState
- .pm3
- .outputStream
- .listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/hf_14b_page.dart b/lib/ui/pages/hf_14b_page.dart
index c66516e..3cbfce9 100644
--- a/lib/ui/pages/hf_14b_page.dart
+++ b/lib/ui/pages/hf_14b_page.dart
@@ -51,7 +51,7 @@ class _Hf14bPageState extends State
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/hf_15_page.dart b/lib/ui/pages/hf_15_page.dart
index cc5f6dc..0defc8f 100644
--- a/lib/ui/pages/hf_15_page.dart
+++ b/lib/ui/pages/hf_15_page.dart
@@ -51,7 +51,7 @@ class _Hf15PageState extends State
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/hf_emv_page.dart b/lib/ui/pages/hf_emv_page.dart
index f2829e8..c3594bb 100644
--- a/lib/ui/pages/hf_emv_page.dart
+++ b/lib/ui/pages/hf_emv_page.dart
@@ -36,7 +36,7 @@ class _HfEmvPageState extends State {
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/hf_felica_page.dart b/lib/ui/pages/hf_felica_page.dart
index aef6806..0941dfa 100644
--- a/lib/ui/pages/hf_felica_page.dart
+++ b/lib/ui/pages/hf_felica_page.dart
@@ -50,7 +50,7 @@ class _HfFelicaPageState extends State
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/hf_fido_page.dart b/lib/ui/pages/hf_fido_page.dart
index 39d2d13..7e381d2 100644
--- a/lib/ui/pages/hf_fido_page.dart
+++ b/lib/ui/pages/hf_fido_page.dart
@@ -36,7 +36,7 @@ class _HfFidoPageState extends State {
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/hf_iclass_page.dart b/lib/ui/pages/hf_iclass_page.dart
index 75a49d9..21e444a 100644
--- a/lib/ui/pages/hf_iclass_page.dart
+++ b/lib/ui/pages/hf_iclass_page.dart
@@ -52,7 +52,7 @@ class _HfIclassPageState extends State
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/hf_legic_page.dart b/lib/ui/pages/hf_legic_page.dart
index fc10457..084719a 100644
--- a/lib/ui/pages/hf_legic_page.dart
+++ b/lib/ui/pages/hf_legic_page.dart
@@ -50,7 +50,7 @@ class _HfLegicPageState extends State
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/hf_mfdes_page.dart b/lib/ui/pages/hf_mfdes_page.dart
index 9caab20..fd80cce 100644
--- a/lib/ui/pages/hf_mfdes_page.dart
+++ b/lib/ui/pages/hf_mfdes_page.dart
@@ -54,7 +54,7 @@ class _HfMfdesPageState extends State
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/hf_mfu_page.dart b/lib/ui/pages/hf_mfu_page.dart
index eb84dd7..90e74dd 100644
--- a/lib/ui/pages/hf_mfu_page.dart
+++ b/lib/ui/pages/hf_mfu_page.dart
@@ -55,7 +55,7 @@ class _HfMfuPageState extends State
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/hf_seos_page.dart b/lib/ui/pages/hf_seos_page.dart
index e1c5d9d..b2a60fb 100644
--- a/lib/ui/pages/hf_seos_page.dart
+++ b/lib/ui/pages/hf_seos_page.dart
@@ -36,7 +36,7 @@ class _HfSeosPageState extends State {
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/hf_sniff_page.dart b/lib/ui/pages/hf_sniff_page.dart
index f3f8312..a119f42 100644
--- a/lib/ui/pages/hf_sniff_page.dart
+++ b/lib/ui/pages/hf_sniff_page.dart
@@ -47,7 +47,7 @@ class _HfSniffPageState extends State {
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/lf_awid_page.dart b/lib/ui/pages/lf_awid_page.dart
index a6f0cff..25b48bd 100644
--- a/lib/ui/pages/lf_awid_page.dart
+++ b/lib/ui/pages/lf_awid_page.dart
@@ -40,7 +40,7 @@ class _LfAwidPageState extends State {
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/lf_fdxb_page.dart b/lib/ui/pages/lf_fdxb_page.dart
index d178df8..5b4654e 100644
--- a/lib/ui/pages/lf_fdxb_page.dart
+++ b/lib/ui/pages/lf_fdxb_page.dart
@@ -37,7 +37,7 @@ class _LfFdxbPageState extends State {
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/lf_hid_page.dart b/lib/ui/pages/lf_hid_page.dart
index 5aa131a..3ea8377 100644
--- a/lib/ui/pages/lf_hid_page.dart
+++ b/lib/ui/pages/lf_hid_page.dart
@@ -50,7 +50,7 @@ class _LfHidPageState extends State
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/lf_hitag_page.dart b/lib/ui/pages/lf_hitag_page.dart
index c91c32a..80ea834 100644
--- a/lib/ui/pages/lf_hitag_page.dart
+++ b/lib/ui/pages/lf_hitag_page.dart
@@ -36,7 +36,7 @@ class _LfHitagPageState extends State {
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/lf_indala_page.dart b/lib/ui/pages/lf_indala_page.dart
index 7ff249f..eab11eb 100644
--- a/lib/ui/pages/lf_indala_page.dart
+++ b/lib/ui/pages/lf_indala_page.dart
@@ -37,7 +37,7 @@ class _LfIndalaPageState extends State {
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/lf_io_page.dart b/lib/ui/pages/lf_io_page.dart
index c03cbd1..39a1291 100644
--- a/lib/ui/pages/lf_io_page.dart
+++ b/lib/ui/pages/lf_io_page.dart
@@ -37,7 +37,7 @@ class _LfIoPageState extends State {
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/lf_keri_page.dart b/lib/ui/pages/lf_keri_page.dart
index f9c80dd..319e8f3 100644
--- a/lib/ui/pages/lf_keri_page.dart
+++ b/lib/ui/pages/lf_keri_page.dart
@@ -37,7 +37,7 @@ class _LfKeriPageState extends State {
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/lf_page.dart b/lib/ui/pages/lf_page.dart
index ab93dbb..0b9803b 100644
--- a/lib/ui/pages/lf_page.dart
+++ b/lib/ui/pages/lf_page.dart
@@ -54,7 +54,7 @@ class _LfPageState extends State with SingleTickerProviderStateMixin {
final buf = StringBuffer();
_sub?.cancel();
- _sub = appState.pm3.outputStream.listen((line) {
+ _sub = appState.pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/lf_pyramid_page.dart b/lib/ui/pages/lf_pyramid_page.dart
index ae78e13..945eef4 100644
--- a/lib/ui/pages/lf_pyramid_page.dart
+++ b/lib/ui/pages/lf_pyramid_page.dart
@@ -39,7 +39,7 @@ class _LfPyramidPageState extends State {
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/mifare_page.dart b/lib/ui/pages/mifare_page.dart
index 06b5a09..edf625a 100644
--- a/lib/ui/pages/mifare_page.dart
+++ b/lib/ui/pages/mifare_page.dart
@@ -70,7 +70,7 @@ class _MifarePageState extends State
final buffer = StringBuffer();
_outputSub?.cancel();
- _outputSub = appState.pm3.outputStream.listen((line) {
+ _outputSub = appState.pm3Output.listen((line) {
// 过滤掉 [pm3] 自身发送的命令回显
if (!line.startsWith('[pm3]')) {
buffer.writeln(line);
diff --git a/lib/ui/pages/nfc_page.dart b/lib/ui/pages/nfc_page.dart
index 69686be..22ffb96 100644
--- a/lib/ui/pages/nfc_page.dart
+++ b/lib/ui/pages/nfc_page.dart
@@ -37,7 +37,7 @@ class _NfcPageState extends State {
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/script_page.dart b/lib/ui/pages/script_page.dart
index 5be505c..d8d09e4 100644
--- a/lib/ui/pages/script_page.dart
+++ b/lib/ui/pages/script_page.dart
@@ -37,7 +37,7 @@ class _ScriptPageState extends State {
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/lib/ui/pages/trace_page.dart b/lib/ui/pages/trace_page.dart
index faa45c8..09c505d 100644
--- a/lib/ui/pages/trace_page.dart
+++ b/lib/ui/pages/trace_page.dart
@@ -53,7 +53,7 @@ class _TracePageState extends State {
});
final buf = StringBuffer();
_sub?.cancel();
- _sub = context.read().pm3.outputStream.listen((line) {
+ _sub = context.read().pm3Output.listen((line) {
if (!line.startsWith('[pm3]')) {
buf.writeln(line);
if (mounted) setState(() => _result = buf.toString());
diff --git a/test/backend/pm3_controller_test.dart b/test/backend/pm3_controller_test.dart
new file mode 100644
index 0000000..1958df7
--- /dev/null
+++ b/test/backend/pm3_controller_test.dart
@@ -0,0 +1,46 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:pm3gui/backend/mock/mock_pm3_backend.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';
+
+void main() {
+ test('controller connects and returns a structured command result', () async {
+ final backend = MockPm3Backend(responses: const {
+ 'hw.version': '[+] client: mock-v1',
+ });
+ final controller = Pm3Controller(backend);
+ addTearDown(controller.dispose);
+
+ final output = [];
+ final subscription = controller.outputLines.listen(output.add);
+ addTearDown(subscription.cancel);
+
+ final connected = await controller.connect(const Pm3ConnectionConfig(
+ executable: 'proxmark3',
+ port: 'mock',
+ ));
+ final result = await controller.execute(const Pm3Command(
+ id: 'hw.version',
+ executable: 'hw version',
+ ));
+
+ expect(connected, isTrue);
+ expect(controller.state, Pm3ConnectionState.connected);
+ expect(result.output, '[+] client: mock-v1');
+ expect(output, ['[+] client: mock-v1']);
+ });
+
+ test('controller rejects commands while disconnected', () async {
+ final controller = Pm3Controller(MockPm3Backend());
+ addTearDown(controller.dispose);
+
+ expect(
+ controller.execute(const Pm3Command(
+ id: 'hf.14a.reader',
+ executable: 'hf 14a reader',
+ )),
+ throwsStateError,
+ );
+ });
+}