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 @@
Flutter Dart - Platforms + Platforms License Status
@@ -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, + ); + }); +}