From a1d03fec2a6abee7c2d75b6376e65e05b89f976f Mon Sep 17 00:00:00 2001 From: Amal Graafstra Date: Tue, 18 Nov 2025 22:20:13 -0800 Subject: [PATCH] Release v1.0.3: Enhanced NDEF reading and UX improvements Major Improvements: - Implement exclusive card access with retry logic and exponential backoff - Fix Type 4 NDEF length parsing (now correctly reads NTAG424 and similar chips) - Add enhanced card type detection (Type 2 vs Type 4) - Implement chunked reading for large NDEF messages - Add proper TLV parsing for Type 2 tags Bug Fixes: - Fix WPF window lifecycle issue (visual tree error on reopen) - Fix NDEF length parsing incorrectly detecting extended format - Correct data offset for Type 4 tag reading New Features: - Multi-line log selection and copy to clipboard - Context menu with Copy Selected Lines, Copy All, Select All - Runtime version roll-forward support (.NET 8.0.x compatibility) Technical Details: - Type 4 tags now use correct 2-byte NLEN field per NFC Forum spec - Removed incorrect 3-byte extended length detection - Window now hides instead of closing for proper tray app behavior - Connection attempts exclusive access first, falls back to shared mode - Status timeout increased from 0ms to 1000ms for better card detection --- Installer/Product.wxs | 2 +- NfcActions/App.xaml.cs | 37 +- NfcActions/MainWindow.xaml | 12 +- NfcActions/MainWindow.xaml.cs | 86 ++++- NfcActions/NfcActions.csproj | 7 +- NfcActions/Services/CardReaderService.cs | 472 ++++++++++++++++++++++- 6 files changed, 585 insertions(+), 31 deletions(-) diff --git a/Installer/Product.wxs b/Installer/Product.wxs index cb27fa1..fb8e941 100644 --- a/Installer/Product.wxs +++ b/Installer/Product.wxs @@ -3,7 +3,7 @@ diff --git a/NfcActions/App.xaml.cs b/NfcActions/App.xaml.cs index 9c929de..897bb76 100644 --- a/NfcActions/App.xaml.cs +++ b/NfcActions/App.xaml.cs @@ -24,6 +24,7 @@ public partial class App : Application private ActionService? _actionService; private LogService? _logService; private MainViewModel? _viewModel; + private System.ComponentModel.CancelEventHandler? _windowClosingHandler; public App() { @@ -121,16 +122,44 @@ public partial class App : Application private void ShowMainWindow() { - if (_mainWindow != null) + // If window was closed, recreate it + if (_mainWindow == null || !_mainWindow.IsLoaded) { - _mainWindow.Show(); - _mainWindow.WindowState = WindowState.Normal; - _mainWindow.Activate(); + if (_viewModel == null) return; + + _mainWindow = new MainWindow(_viewModel); + + // Handle window closing - hide instead of close + _windowClosingHandler = (s, e) => + { + e.Cancel = true; + if (_mainWindow != null) + { + _mainWindow.Hide(); + } + }; + + _mainWindow.Closing += _windowClosingHandler; } + + _mainWindow.Show(); + _mainWindow.WindowState = WindowState.Normal; + _mainWindow.Activate(); } private void ExitApplication() { + // Properly close the main window if it exists + if (_mainWindow != null) + { + // Remove the cancel handler so the window can actually close + if (_windowClosingHandler != null) + { + _mainWindow.Closing -= _windowClosingHandler; + } + _mainWindow.Close(); + } + _notifyIcon?.Dispose(); _customIcon?.Dispose(); _cardReaderService?.Dispose(); diff --git a/NfcActions/MainWindow.xaml b/NfcActions/MainWindow.xaml index c386bb5..9c02b03 100644 --- a/NfcActions/MainWindow.xaml +++ b/NfcActions/MainWindow.xaml @@ -99,10 +99,20 @@ + ScrollViewer.VerticalScrollBarVisibility="Auto" + SelectionMode="Extended"> + + + + + + + + ().ToList(); + if (selectedItems.Count == 0) + { + MessageBox.Show("No log entries selected. Please select one or more lines first.", + "No Selection", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + var logText = new StringBuilder(); + foreach (var entry in selectedItems) + { + logText.AppendLine(entry.FormattedMessage); + } + + Clipboard.SetText(logText.ToString()); + + MessageBox.Show($"Copied {selectedItems.Count} log {(selectedItems.Count == 1 ? "entry" : "entries")} to clipboard.", + "Copied", + MessageBoxButton.OK, + MessageBoxImage.Information); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to copy to clipboard: {ex.Message}", + "Error", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + } + + private void CopyAllLogs_Click(object sender, RoutedEventArgs e) + { + try + { + if (DataContext is MainViewModel viewModel) + { + var allEntries = viewModel.LogEntries.ToList(); + if (allEntries.Count == 0) + { + MessageBox.Show("No log entries available.", + "Empty Log", + MessageBoxButton.OK, + MessageBoxImage.Information); + return; + } + + var logText = new StringBuilder(); + foreach (var entry in allEntries) + { + logText.AppendLine(entry.FormattedMessage); + } + + Clipboard.SetText(logText.ToString()); + + MessageBox.Show($"Copied all {allEntries.Count} log entries to clipboard.", + "Copied", + MessageBoxButton.OK, + MessageBoxImage.Information); + } + } + catch (Exception ex) + { + MessageBox.Show($"Failed to copy to clipboard: {ex.Message}", + "Error", + MessageBoxButton.OK, + MessageBoxImage.Error); + } + } + + private void SelectAllLogs_Click(object sender, RoutedEventArgs e) + { + LogListBox.SelectAll(); + } } diff --git a/NfcActions/NfcActions.csproj b/NfcActions/NfcActions.csproj index d2f2489..4127a9b 100644 --- a/NfcActions/NfcActions.csproj +++ b/NfcActions/NfcActions.csproj @@ -7,13 +7,14 @@ true true Resources\icon.ico - 1.0.2 - 1.0.2.0 - 1.0.2.0 + 1.0.3 + 1.0.3.0 + 1.0.3.0 Dangerous Things NFC Actions NFC card reader monitoring and action automation Copyright © 2025 Dangerous Things + LatestMinor diff --git a/NfcActions/Services/CardReaderService.cs b/NfcActions/Services/CardReaderService.cs index 3a5d54d..dfe9b09 100644 --- a/NfcActions/Services/CardReaderService.cs +++ b/NfcActions/Services/CardReaderService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using PCSC; +using PCSC.Exceptions; namespace NfcActions.Services; @@ -16,6 +17,9 @@ public class CardReaderService : IDisposable private readonly LogService? _logService; private const int POLL_INTERVAL_MS = 500; + private const int MAX_RETRY_ATTEMPTS = 3; + private const int INITIAL_RETRY_DELAY_MS = 100; + private const int STATUS_TIMEOUT_MS = 1000; public event EventHandler? ReaderAdded; public event EventHandler? ReaderRemoved; @@ -150,7 +154,7 @@ public class CardReaderService : IDisposable } }; - var result = context.GetStatusChange(0, readerStates); + var result = context.GetStatusChange(STATUS_TIMEOUT_MS, readerStates); if (result == SCardError.Success && readerStates.Length > 0) { @@ -215,6 +219,112 @@ public class CardReaderService : IDisposable } } + private ICardReader? ConnectReaderWithRetry(ISCardContext context, string readerName) + { + int retryDelay = INITIAL_RETRY_DELAY_MS; + + for (int attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) + { + try + { + _logService?.Debug($"Attempting to connect to reader (attempt {attempt}/{MAX_RETRY_ATTEMPTS})..."); + + // Try exclusive mode first + try + { + var reader = context.ConnectReader(readerName, SCardShareMode.Exclusive, SCardProtocol.Any); + _logService?.Debug($"Successfully connected with exclusive access on attempt {attempt}"); + return reader; + } + catch (PCSCException ex) when (ex.SCardError == SCardError.SharingViolation && attempt < MAX_RETRY_ATTEMPTS) + { + _logService?.Debug($"Exclusive access denied (sharing violation), retrying in {retryDelay}ms..."); + Thread.Sleep(retryDelay); + retryDelay *= 2; // Exponential backoff + continue; + } + } + catch (Exception ex) + { + _logService?.Warning($"Connection attempt {attempt} failed: {ex.Message}"); + + if (attempt == MAX_RETRY_ATTEMPTS) + { + // On final attempt, try shared mode as fallback + try + { + _logService?.Warning("All exclusive access attempts failed, falling back to shared mode..."); + var reader = context.ConnectReader(readerName, SCardShareMode.Shared, SCardProtocol.Any); + _logService?.Warning("Connected with shared access (fallback)"); + return reader; + } + catch (Exception fallbackEx) + { + _logService?.Error($"Failed to connect even with shared mode: {fallbackEx.Message}"); + throw; + } + } + + Thread.Sleep(retryDelay); + retryDelay *= 2; // Exponential backoff + } + } + + return null; + } + + private enum NfcCardType + { + Unknown, + Type2, // NTAG, MIFARE Ultralight + Type4 // ISO-DEP, DESFire + } + + private NfcCardType DetectCardType(ICardReader reader, byte[]? atr) + { + try + { + // Check ATR for Type 4 indicators + if (atr != null && atr.Length > 0) + { + // Check for ISO 14443-4 support in historical bytes + _logService?.Debug($"Analyzing ATR for card type detection: {BitConverter.ToString(atr).Replace("-", " ")}"); + + // Type 4 cards typically have longer ATRs with historical bytes + if (atr.Length > 10) + { + _logService?.Debug("Long ATR detected, likely Type 4 card"); + return NfcCardType.Type4; + } + } + + // Try to detect card type using protocol + if (reader.Protocol == SCardProtocol.T0 || reader.Protocol == SCardProtocol.T1) + { + _logService?.Debug($"T=0/T=1 protocol detected, likely Type 4 card"); + // T=0 or T=1 protocols typically indicate Type 4 cards + } + + // Try a simple Type 2 read command + var testRead = new byte[] { 0x30, 0x00 }; // READ block 0 + var response = TransmitApdu(reader, testRead, "Type 2 detection probe"); + if (response != null && response.Length >= 16) + { + _logService?.Debug("Type 2 READ command successful"); + return NfcCardType.Type2; + } + + // Default to Type 4 for ISO-DEP cards + _logService?.Debug("Defaulting to Type 4 card"); + return NfcCardType.Type4; + } + catch (Exception ex) + { + _logService?.Debug($"Card type detection failed: {ex.Message}"); + return NfcCardType.Unknown; + } + } + private byte[]? ReadCardData(string readerName) { try @@ -224,7 +334,13 @@ public class CardReaderService : IDisposable using var context = ContextFactory.Instance.Establish(SCardScope.System); _logService?.Debug("Connecting to reader..."); - using var reader = context.ConnectReader(readerName, SCardShareMode.Shared, SCardProtocol.Any); + using var reader = ConnectReaderWithRetry(context, readerName); + + if (reader == null) + { + _logService?.Error("Failed to connect to reader after all retry attempts"); + return null; + } _logService?.Debug($"Connected. Active protocol: {reader.Protocol}"); @@ -235,32 +351,55 @@ public class CardReaderService : IDisposable _logService?.Debug($"ATR: {BitConverter.ToString(atr).Replace("-", " ")}"); } + // Detect card type + var cardType = DetectCardType(reader, atr); + _logService?.Info($"Detected card type: {cardType}"); + byte[]? ndefData = null; - // Strategy 1: Try Type 4 Tag (ISO 14443-4 / ISO-DEP) - _logService?.Debug("=== Attempting Type 4 Tag NDEF read ==="); - ndefData = TryReadType4Tag(reader); - if (ndefData != null && ndefData.Length > 0) + // Use appropriate strategy based on detected card type + switch (cardType) { - _logService?.Info("Successfully read NDEF data using Type 4 method"); - return ndefData; + case NfcCardType.Type2: + _logService?.Debug("=== Reading Type 2 Tag ==="); + ndefData = TryReadType2TagEnhanced(reader); + if (ndefData != null && ndefData.Length > 0) + { + _logService?.Info("Successfully read NDEF data from Type 2 tag"); + return ndefData; + } + // Fallback to Type 4 if Type 2 fails + _logService?.Debug("Type 2 read failed, trying Type 4 as fallback"); + ndefData = TryReadType4TagEnhanced(reader); + break; + + case NfcCardType.Type4: + _logService?.Debug("=== Reading Type 4 Tag ==="); + ndefData = TryReadType4TagEnhanced(reader); + if (ndefData != null && ndefData.Length > 0) + { + _logService?.Info("Successfully read NDEF data from Type 4 tag"); + return ndefData; + } + // Fallback to Type 2 if Type 4 fails + _logService?.Debug("Type 4 read failed, trying Type 2 as fallback"); + ndefData = TryReadType2TagEnhanced(reader); + break; + + default: + // Try both methods if unknown + _logService?.Debug("Unknown card type, trying all methods"); + ndefData = TryReadType4TagEnhanced(reader); + if (ndefData == null || ndefData.Length == 0) + { + ndefData = TryReadType2TagEnhanced(reader); + } + break; } - // Strategy 2: Try direct NDEF file read - _logService?.Debug("=== Attempting direct NDEF file read ==="); - ndefData = TryReadNdefDirect(reader); if (ndefData != null && ndefData.Length > 0) { - _logService?.Info("Successfully read NDEF data using direct method"); - return ndefData; - } - - // Strategy 3: Try reading raw tag memory (Type 2) - _logService?.Debug("=== Attempting Type 2 Tag read ==="); - ndefData = TryReadType2Tag(reader); - if (ndefData != null && ndefData.Length > 0) - { - _logService?.Info("Successfully read data using Type 2 method"); + _logService?.Info($"Successfully read {ndefData.Length} bytes of NDEF data"); return ndefData; } @@ -275,6 +414,297 @@ public class CardReaderService : IDisposable } } + private byte[]? TryReadType4TagEnhanced(ICardReader reader) + { + try + { + // Step 1: Select NDEF Tag Application (AID: D2760000850101) + var selectNdef = new byte[] { 0x00, 0xA4, 0x04, 0x00, 0x07, 0xD2, 0x76, 0x00, 0x00, 0x85, 0x01, 0x01, 0x00 }; + var response = TransmitApdu(reader, selectNdef, "Select NDEF Application"); + + if (!IsSuccess(response)) + { + _logService?.Debug("NDEF application not found"); + return null; + } + + // Step 2: Select Capability Container (CC) file + var selectCC = new byte[] { 0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x03 }; + response = TransmitApdu(reader, selectCC, "Select CC File"); + + if (!IsSuccess(response)) + { + _logService?.Debug("Failed to select CC file"); + return null; + } + + // Step 3: Read CC to get NDEF file info + var readCC = new byte[] { 0x00, 0xB0, 0x00, 0x00, 0x0F }; + response = TransmitApdu(reader, readCC, "Read CC"); + + if (!IsSuccess(response) || response?.Length < 17) + { + _logService?.Debug("Failed to read CC"); + return null; + } + + // Parse CC to get max NDEF size + var ccData = new byte[15]; + if (response != null) + { + Array.Copy(response, ccData, Math.Min(15, response.Length - 2)); + } + + // CC format: 2 bytes length, 1 byte version, 2 bytes MLe, 2 bytes MLc, then TLV + var maxNdefSize = (ccData[3] << 8) | ccData[4]; // MLe (Maximum Length for data read) + var maxApduSize = (ccData[5] << 8) | ccData[6]; // MLc (Maximum Length for command) + _logService?.Debug($"CC: Max NDEF size = {maxNdefSize}, Max APDU size = {maxApduSize}"); + + // Step 4: Select NDEF file + var selectNdefFile = new byte[] { 0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x04 }; + response = TransmitApdu(reader, selectNdefFile, "Select NDEF File"); + + if (!IsSuccess(response)) + { + _logService?.Debug("Failed to select NDEF file"); + return null; + } + + // Step 5: Read NDEF length (always 2 bytes for Type 4 tags per NFC Forum spec) + var readLength = new byte[] { 0x00, 0xB0, 0x00, 0x00, 0x02 }; // Read 2 bytes + response = TransmitApdu(reader, readLength, "Read NDEF Length"); + + if (response == null || response.Length < 4) + { + _logService?.Debug("Failed to read NDEF length"); + return null; + } + + // Type 4 tags use 2-byte NLEN field (big-endian) + int ndefLength = (response[0] << 8) | response[1]; + int dataOffset = 2; // NDEF message starts at byte 2 + + _logService?.Debug($"NDEF length: {ndefLength} bytes (0x{response[0]:X2}{response[1]:X2})"); + + if (ndefLength == 0) + { + _logService?.Warning("NDEF length is 0 - empty tag"); + return null; + } + + if (ndefLength > 65535) + { + _logService?.Warning($"NDEF length too large: {ndefLength}"); + return null; + } + + // Step 6: Read NDEF data in chunks + var allData = new List(); + var offset = dataOffset; + var maxReadSize = Math.Min(maxApduSize > 0 ? maxApduSize : 250, 250); // Use CC info or default to 250 + + while (allData.Count < ndefLength) + { + var remainingBytes = ndefLength - allData.Count; + var readSize = Math.Min(remainingBytes, maxReadSize); + + var readData = new byte[] { + 0x00, 0xB0, + (byte)(offset >> 8), (byte)(offset & 0xFF), + (byte)readSize + }; + + response = TransmitApdu(reader, readData, $"Read NDEF chunk at offset {offset}"); + + if (!IsSuccess(response)) + { + _logService?.Error($"Failed to read NDEF data at offset {offset}"); + break; + } + + var dataLength = response?.Length - 2 ?? 0; + if (dataLength > 0 && response != null) + { + for (int i = 0; i < dataLength && allData.Count < ndefLength; i++) + { + allData.Add(response[i]); + } + offset += dataLength; + } + else + { + _logService?.Warning("No data received in chunk read"); + break; + } + + _logService?.Debug($"Read {dataLength} bytes, total: {allData.Count}/{ndefLength}"); + } + + if (allData.Count > 0) + { + _logService?.Info($"Successfully read {allData.Count} bytes from Type 4 tag"); + return allData.ToArray(); + } + + return null; + } + catch (Exception ex) + { + _logService?.Debug($"Type 4 enhanced read exception: {ex.Message}"); + return null; + } + } + + private byte[]? TryReadType2TagEnhanced(ICardReader reader) + { + try + { + _logService?.Debug("Starting enhanced Type 2 tag read"); + + // Step 1: Read first 16 bytes (blocks 0-3) to identify tag + var readHeader = new byte[] { 0x30, 0x00 }; // READ from block 0 + var response = TransmitApdu(reader, readHeader, "Read header blocks 0-3"); + + if (response == null || response.Length < 16) + { + _logService?.Debug("Failed to read header blocks"); + return null; + } + + // Step 2: Check Capability Container (CC) at block 3 (bytes 12-15) + // CC format: Magic number, Version, Memory size, Read/Write access + if (response.Length >= 16) + { + var cc0 = response[12]; // Should be 0xE1 for NDEF + var cc1 = response[13]; // Version + var cc2 = response[14]; // Memory size + var cc3 = response[15]; // Read/Write access + + _logService?.Debug($"CC: {cc0:X2} {cc1:X2} {cc2:X2} {cc3:X2}"); + + if (cc0 != 0xE1) + { + _logService?.Debug("Not an NDEF formatted tag (CC0 != 0xE1)"); + // Continue anyway as some tags might still have NDEF data + } + + var memorySize = (cc2 & 0xFF) * 8; + _logService?.Debug($"Tag memory size: {memorySize} bytes"); + } + + // Step 3: Read NDEF data starting from block 4 + var allData = new List(); + byte currentBlock = 4; + int tlvPosition = 0; + bool ndefFound = false; + int ndefLength = 0; + + // Read up to 64 blocks (256 bytes) or until terminator + while (currentBlock < 64) + { + var readBlock = new byte[] { 0x30, currentBlock }; + response = TransmitApdu(reader, readBlock, $"Read block {currentBlock}"); + + if (response == null || response.Length < 16) + { + _logService?.Debug($"Failed to read block {currentBlock}"); + break; + } + + // Process 4 blocks (16 bytes) at a time + for (int i = 0; i < 16 && (currentBlock * 4 + i / 4) < 256; i++) + { + allData.Add(response[i]); + + // Parse TLV structure to find NDEF message + if (!ndefFound && allData.Count >= 2) + { + // Check for NDEF TLV (0x03) + if (allData[tlvPosition] == 0x03) + { + ndefFound = true; + _logService?.Debug($"Found NDEF TLV at position {tlvPosition}"); + + // Parse length + if (allData.Count > tlvPosition + 1) + { + var lengthByte = allData[tlvPosition + 1]; + if (lengthByte == 0xFF && allData.Count > tlvPosition + 3) + { + // 3-byte length + ndefLength = (allData[tlvPosition + 2] << 8) | allData[tlvPosition + 3]; + tlvPosition += 4; // Skip TLV header + } + else + { + // 1-byte length + ndefLength = lengthByte; + tlvPosition += 2; // Skip TLV header + } + _logService?.Debug($"NDEF length: {ndefLength} bytes"); + } + } + else if (allData[tlvPosition] == 0xFE) + { + // Terminator TLV + _logService?.Debug("Found terminator TLV"); + break; + } + else if (allData[tlvPosition] == 0x00) + { + // NULL TLV, skip + tlvPosition++; + } + else + { + // Other TLV, skip based on length + if (allData.Count > tlvPosition + 1) + { + var len = allData[tlvPosition + 1]; + tlvPosition += 2 + len; + } + } + } + } + + // Check if we've read enough NDEF data + if (ndefFound && allData.Count >= tlvPosition + ndefLength) + { + _logService?.Debug("Read complete NDEF message"); + break; + } + + currentBlock += 4; // Move to next set of 4 blocks + } + + // Extract NDEF data if found + if (ndefFound && ndefLength > 0 && allData.Count >= tlvPosition + ndefLength) + { + var ndefData = new byte[ndefLength]; + for (int i = 0; i < ndefLength; i++) + { + ndefData[i] = allData[tlvPosition + i]; + } + _logService?.Info($"Successfully extracted {ndefLength} bytes of NDEF data from Type 2 tag"); + return ndefData; + } + + // If no NDEF TLV found but we have data, return raw data + if (allData.Count > 0) + { + _logService?.Warning("No NDEF TLV found, returning raw data"); + return allData.ToArray(); + } + + return null; + } + catch (Exception ex) + { + _logService?.Debug($"Type 2 enhanced read exception: {ex.Message}"); + return null; + } + } + private byte[]? TryReadType4Tag(ICardReader reader) { try