commit 5b6a7f0fa5bbcfc9b2251ffae66b4498ebe89aa2 Author: Amal Graafstra Date: Thu Nov 6 22:56:11 2025 -0800 Initial commit: NFC Actions Windows tray application Implement PC/SC-based NFC card reader monitoring application with WPF UI. Features include: - System tray integration with single-click to open main window - Dynamic reader detection and management with enable/disable per reader - NDEF payload extraction supporting Type 2 and Type 4 tags - Auto-detection of block sizes (4-byte vs 16-byte) for different reader types - Configurable actions: copy to clipboard, launch URLs, keyboard input simulation - URI record type detection - only launches browser for actual URI records - Real-time activity logging with color-coded levels (Debug, Info, Warning, Error) - File-based debug logging for troubleshooting - Settings persistence between sessions - Dangerous Things branding with custom icons and clickable logo πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b71676b --- /dev/null +++ b/.gitignore @@ -0,0 +1,105 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ + +# Visual Studio Code +.vscode/ + +# ReSharper +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JetBrains Rider +.idea/ +*.sln.iml + +# NuGet Packages +*.nupkg +**/packages/* +!**/packages/build/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# Publish +publish/ + +# macOS +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db diff --git a/NfcActions.sln b/NfcActions.sln new file mode 100644 index 0000000..ece58d1 --- /dev/null +++ b/NfcActions.sln @@ -0,0 +1,22 @@ +ο»Ώ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NfcActions", "NfcActions\NfcActions.csproj", "{50B8E3CE-224F-4B32-B49B-05631DB3D015}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {50B8E3CE-224F-4B32-B49B-05631DB3D015}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {50B8E3CE-224F-4B32-B49B-05631DB3D015}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50B8E3CE-224F-4B32-B49B-05631DB3D015}.Release|Any CPU.ActiveCfg = Release|Any CPU + {50B8E3CE-224F-4B32-B49B-05631DB3D015}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/NfcActions/App.xaml b/NfcActions/App.xaml new file mode 100644 index 0000000..12668a1 --- /dev/null +++ b/NfcActions/App.xaml @@ -0,0 +1,10 @@ +ο»Ώ + + + + diff --git a/NfcActions/App.xaml.cs b/NfcActions/App.xaml.cs new file mode 100644 index 0000000..90cb1d8 --- /dev/null +++ b/NfcActions/App.xaml.cs @@ -0,0 +1,111 @@ +ο»Ώusing System; +using System.Drawing; +using System.Windows; +using System.Windows.Forms; +using NfcActions.Services; +using NfcActions.ViewModels; +using Application = System.Windows.Application; + +namespace NfcActions; + +/// +/// Interaction logic for App.xaml +/// +public partial class App : Application +{ + private NotifyIcon? _notifyIcon; + private Icon? _customIcon; + private MainWindow? _mainWindow; + private CardReaderService? _cardReaderService; + private SettingsService? _settingsService; + private ActionService? _actionService; + private LogService? _logService; + private MainViewModel? _viewModel; + + private void Application_Startup(object sender, StartupEventArgs e) + { + // Initialize services + _logService = new LogService(); + _cardReaderService = new CardReaderService(_logService); + _settingsService = new SettingsService(); + _actionService = new ActionService(); + + // Initialize ViewModel + _viewModel = new MainViewModel(_cardReaderService, _settingsService, _actionService, _logService); + + // Create main window but don't show it yet + _mainWindow = new MainWindow(_viewModel); + + // Load custom icon + try + { + var iconPath = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Resources", "icon.ico"); + if (System.IO.File.Exists(iconPath)) + { + _customIcon = new Icon(iconPath); + } + } + catch + { + // Fall back to default if custom icon can't be loaded + } + + // Create system tray icon + _notifyIcon = new NotifyIcon + { + Icon = _customIcon ?? SystemIcons.Application, + Visible = true, + Text = "NFC Actions" + }; + + // Set up context menu + var contextMenu = new ContextMenuStrip(); + contextMenu.Items.Add("Open", null, (s, args) => ShowMainWindow()); + contextMenu.Items.Add(new ToolStripSeparator()); + contextMenu.Items.Add("Exit", null, (s, args) => ExitApplication()); + + _notifyIcon.ContextMenuStrip = contextMenu; + _notifyIcon.MouseClick += (s, args) => + { + if (args.Button == MouseButtons.Left) + { + ShowMainWindow(); + } + }; + + // Start the card reader service + _cardReaderService.Start(); + + // Show a notification that the app is running + _notifyIcon.ShowBalloonTip( + 3000, + "NFC Actions", + "NFC Actions is now monitoring for card events. Right-click the tray icon to configure.", + ToolTipIcon.Info); + } + + private void ShowMainWindow() + { + if (_mainWindow != null) + { + _mainWindow.Show(); + _mainWindow.WindowState = WindowState.Normal; + _mainWindow.Activate(); + } + } + + private void ExitApplication() + { + _notifyIcon?.Dispose(); + _customIcon?.Dispose(); + _cardReaderService?.Dispose(); + Shutdown(); + } + + private void Application_Exit(object sender, ExitEventArgs e) + { + _notifyIcon?.Dispose(); + _customIcon?.Dispose(); + _cardReaderService?.Dispose(); + } +} diff --git a/NfcActions/AssemblyInfo.cs b/NfcActions/AssemblyInfo.cs new file mode 100644 index 0000000..2211234 --- /dev/null +++ b/NfcActions/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly:ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/NfcActions/MainWindow.xaml b/NfcActions/MainWindow.xaml new file mode 100644 index 0000000..c386bb5 --- /dev/null +++ b/NfcActions/MainWindow.xaml @@ -0,0 +1,149 @@ +ο»Ώ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/NfcActions/MainWindow.xaml.cs b/NfcActions/MainWindow.xaml.cs new file mode 100644 index 0000000..d0627ab --- /dev/null +++ b/NfcActions/MainWindow.xaml.cs @@ -0,0 +1,42 @@ +ο»Ώusing System.ComponentModel; +using System.Diagnostics; +using System.Windows; +using System.Windows.Input; +using NfcActions.ViewModels; + +namespace NfcActions; + +/// +/// Interaction logic for MainWindow.xaml +/// +public partial class MainWindow : Window +{ + public MainWindow(MainViewModel viewModel) + { + InitializeComponent(); + DataContext = viewModel; + } + + private void Window_Closing(object? sender, CancelEventArgs e) + { + // Minimize to tray instead of closing + e.Cancel = true; + Hide(); + } + + private void Logo_Click(object sender, MouseButtonEventArgs e) + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = "https://dangerousthings.com", + UseShellExecute = true + }); + } + catch + { + // Silently fail if browser can't be opened + } + } +} diff --git a/NfcActions/Models/AppSettings.cs b/NfcActions/Models/AppSettings.cs new file mode 100644 index 0000000..b0dfc87 --- /dev/null +++ b/NfcActions/Models/AppSettings.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace NfcActions.Models; + +public class AppSettings +{ + public HashSet DisabledReaders { get; set; } = new(); + public bool CopyToClipboard { get; set; } = false; + public bool LaunchUrls { get; set; } = true; + public bool TypeAsKeyboard { get; set; } = false; +} diff --git a/NfcActions/Models/ReaderItem.cs b/NfcActions/Models/ReaderItem.cs new file mode 100644 index 0000000..fbcdd61 --- /dev/null +++ b/NfcActions/Models/ReaderItem.cs @@ -0,0 +1,30 @@ +using System.ComponentModel; + +namespace NfcActions.Models; + +public class ReaderItem : INotifyPropertyChanged +{ + private bool _isEnabled; + + public string Name { get; set; } = string.Empty; + + public bool IsEnabled + { + get => _isEnabled; + set + { + if (_isEnabled != value) + { + _isEnabled = value; + OnPropertyChanged(nameof(IsEnabled)); + } + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/NfcActions/NfcActions.csproj b/NfcActions/NfcActions.csproj new file mode 100644 index 0000000..5a714ac --- /dev/null +++ b/NfcActions/NfcActions.csproj @@ -0,0 +1,28 @@ +ο»Ώ + + + WinExe + net7.0-windows + enable + true + true + Resources\icon.ico + + + + + + + + + + + + + + + + + + + diff --git a/NfcActions/Resources/icon.ico b/NfcActions/Resources/icon.ico new file mode 100644 index 0000000..601c72a Binary files /dev/null and b/NfcActions/Resources/icon.ico differ diff --git a/NfcActions/Resources/icon.png b/NfcActions/Resources/icon.png new file mode 100644 index 0000000..b09fd60 Binary files /dev/null and b/NfcActions/Resources/icon.png differ diff --git a/NfcActions/Resources/logo.png b/NfcActions/Resources/logo.png new file mode 100644 index 0000000..d0f771f Binary files /dev/null and b/NfcActions/Resources/logo.png differ diff --git a/NfcActions/Services/ActionService.cs b/NfcActions/Services/ActionService.cs new file mode 100644 index 0000000..415b4a1 --- /dev/null +++ b/NfcActions/Services/ActionService.cs @@ -0,0 +1,69 @@ +using System; +using System.Diagnostics; +using System.Windows; +using WindowsInput; +using WindowsInput.Native; + +namespace NfcActions.Services; + +public class ActionService +{ + private readonly InputSimulator _inputSimulator = new(); + + public void CopyToClipboard(string text) + { + try + { + Clipboard.SetText(text); + } + catch (Exception) + { + // Failed to set clipboard + } + } + + public void LaunchUrl(string text) + { + try + { + // Check if the text looks like a URL + if (Uri.TryCreate(text, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + { + Process.Start(new ProcessStartInfo + { + FileName = text, + UseShellExecute = true + }); + } + else if (text.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || + text.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + Process.Start(new ProcessStartInfo + { + FileName = text, + UseShellExecute = true + }); + } + } + catch (Exception) + { + // Failed to launch URL + } + } + + public void TypeText(string text) + { + try + { + // Give a small delay to allow user to position cursor if needed + System.Threading.Thread.Sleep(100); + + _inputSimulator.Keyboard.TextEntry(text); + } + catch (Exception) + { + // Failed to simulate keyboard input + } + } +} diff --git a/NfcActions/Services/CardReaderService.cs b/NfcActions/Services/CardReaderService.cs new file mode 100644 index 0000000..3a5d54d --- /dev/null +++ b/NfcActions/Services/CardReaderService.cs @@ -0,0 +1,544 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using PCSC; + +namespace NfcActions.Services; + +public class CardReaderService : IDisposable +{ + private readonly Timer _pollTimer; + private readonly Dictionary _readerCardPresent = new(); + private readonly HashSet _disabledReaders = new(); + private readonly SynchronizationContext? _syncContext; + private readonly object _lock = new(); + private readonly LogService? _logService; + + private const int POLL_INTERVAL_MS = 500; + + public event EventHandler? ReaderAdded; + public event EventHandler? ReaderRemoved; + public event EventHandler? CardInserted; + public event EventHandler? CardRemoved; + + public CardReaderService(LogService? logService = null) + { + _syncContext = SynchronizationContext.Current; + _pollTimer = new Timer(PollReaders, null, Timeout.Infinite, Timeout.Infinite); + _logService = logService; + } + + public void Start() + { + _logService?.Info("Starting CardReaderService..."); + + // Initialize with current readers + RefreshReaders(); + + // Start polling + _pollTimer.Change(POLL_INTERVAL_MS, POLL_INTERVAL_MS); + _logService?.Info("CardReaderService started successfully"); + } + + private void PollReaders(object? state) + { + try + { + RefreshReaders(); + MonitorCardStates(); + } + catch (Exception) + { + // Error during polling, continue anyway + } + } + + private void RefreshReaders() + { + lock (_lock) + { + try + { + using var context = ContextFactory.Instance.Establish(SCardScope.System); + var currentReaders = context.GetReaders()?.ToList() ?? new List(); + + // Find removed readers + var removedReaders = _readerCardPresent.Keys.Except(currentReaders).ToList(); + foreach (var reader in removedReaders) + { + _readerCardPresent.Remove(reader); + _logService?.Info($"Reader removed: {reader}"); + RaiseEvent(() => ReaderRemoved?.Invoke(this, new ReaderEventArgs(reader))); + } + + // Find new readers + var newReaders = currentReaders.Except(_readerCardPresent.Keys).ToList(); + foreach (var reader in newReaders) + { + _readerCardPresent[reader] = false; + _logService?.Info($"Reader added: {reader}"); + RaiseEvent(() => ReaderAdded?.Invoke(this, new ReaderEventArgs(reader))); + } + } + catch (Exception ex) + { + _logService?.Error($"Error refreshing readers: {ex.Message}"); + } + } + } + + private void MonitorCardStates() + { + lock (_lock) + { + foreach (var readerName in _readerCardPresent.Keys.ToList()) + { + // Skip disabled readers + if (_disabledReaders.Contains(readerName)) + continue; + + try + { + var isPresent = IsCardPresent(readerName); + var wasPresent = _readerCardPresent[readerName]; + + if (isPresent && !wasPresent) + { + _readerCardPresent[readerName] = true; + _logService?.Info($"Card inserted on reader: {readerName}"); + var cardData = ReadCardData(readerName); + + if (cardData != null && cardData.Length > 0) + { + _logService?.Debug($"Read {cardData.Length} bytes from card"); + _logService?.Debug($"Card data (hex): {BitConverter.ToString(cardData).Replace("-", " ")}"); + } + else + { + _logService?.Warning("No data read from card"); + } + + RaiseEvent(() => CardInserted?.Invoke(this, new CardEventArgs(readerName, cardData))); + } + else if (!isPresent && wasPresent) + { + _readerCardPresent[readerName] = false; + _logService?.Info($"Card removed from reader: {readerName}"); + RaiseEvent(() => CardRemoved?.Invoke(this, new CardEventArgs(readerName, null))); + } + } + catch (Exception ex) + { + _logService?.Error($"Error monitoring reader {readerName}: {ex.Message}"); + } + } + } + } + + private bool IsCardPresent(string readerName) + { + try + { + using var context = ContextFactory.Instance.Establish(SCardScope.System); + + var readerStates = new[] + { + new SCardReaderState + { + ReaderName = readerName + } + }; + + var result = context.GetStatusChange(0, readerStates); + + if (result == SCardError.Success && readerStates.Length > 0) + { + var state = readerStates[0].EventState; + return (state & SCRState.Present) == SCRState.Present; + } + + return false; + } + catch + { + return false; + } + } + + public List GetAvailableReaders() + { + try + { + using var context = ContextFactory.Instance.Establish(SCardScope.System); + return context.GetReaders()?.ToList() ?? new List(); + } + catch + { + return new List(); + } + } + + public void EnableReader(string readerName) + { + lock (_lock) + { + _disabledReaders.Remove(readerName); + } + } + + public void DisableReader(string readerName) + { + lock (_lock) + { + _disabledReaders.Add(readerName); + } + } + + public bool IsReaderEnabled(string readerName) + { + lock (_lock) + { + return !_disabledReaders.Contains(readerName); + } + } + + public void SetDisabledReaders(IEnumerable disabledReaders) + { + lock (_lock) + { + _disabledReaders.Clear(); + foreach (var reader in disabledReaders) + { + _disabledReaders.Add(reader); + } + } + } + + private byte[]? ReadCardData(string readerName) + { + try + { + _logService?.Debug($"--- Starting card read from {readerName} ---"); + + using var context = ContextFactory.Instance.Establish(SCardScope.System); + + _logService?.Debug("Connecting to reader..."); + using var reader = context.ConnectReader(readerName, SCardShareMode.Shared, SCardProtocol.Any); + + _logService?.Debug($"Connected. Active protocol: {reader.Protocol}"); + + // Get ATR (Answer To Reset) + var atr = reader.GetAttrib(SCardAttribute.AtrString); + if (atr != null && atr.Length > 0) + { + _logService?.Debug($"ATR: {BitConverter.ToString(atr).Replace("-", " ")}"); + } + + 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) + { + _logService?.Info("Successfully read NDEF data using Type 4 method"); + return ndefData; + } + + // 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"); + return ndefData; + } + + _logService?.Warning("All read strategies failed - no NDEF data retrieved"); + return null; + } + catch (Exception ex) + { + _logService?.Error($"Exception in ReadCardData: {ex.Message}"); + _logService?.Debug($"Stack trace: {ex.StackTrace}"); + return null; + } + } + + private byte[]? TryReadType4Tag(ICardReader reader) + { + try + { + // 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 (this is normal for non-Type 4 tags)"); + return null; + } + + // Select Capability Container file + var selectCC = new byte[] { 0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x03 }; + response = TransmitApdu(reader, selectCC, "Select CC File"); + + if (IsSuccess(response)) + { + // Read CC + var readCC = new byte[] { 0x00, 0xB0, 0x00, 0x00, 0x0F }; + response = TransmitApdu(reader, readCC, "Read CC"); + } + + // Select NDEF file + var selectNdefFile = new byte[] { 0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x04 }; + response = TransmitApdu(reader, selectNdefFile, "Select NDEF File"); + + if (!IsSuccess(response)) + { + return null; + } + + // Read NDEF length (first 2 bytes) + var readLength = new byte[] { 0x00, 0xB0, 0x00, 0x00, 0x02 }; + response = TransmitApdu(reader, readLength, "Read NDEF Length"); + + if (response == null || response.Length < 4) + { + return null; + } + + int ndefLength = (response[0] << 8) | response[1]; + _logService?.Debug($"NDEF message length: {ndefLength} bytes"); + + if (ndefLength == 0 || ndefLength > 8192) + { + _logService?.Warning($"Invalid NDEF length: {ndefLength}"); + return null; + } + + // Read actual NDEF data + var readNdef = new byte[] { 0x00, 0xB0, 0x00, 0x02, (byte)Math.Min(ndefLength, 250) }; + response = TransmitApdu(reader, readNdef, "Read NDEF Data"); + + if (response != null && response.Length > 2) + { + var data = new byte[response.Length - 2]; + Array.Copy(response, data, data.Length); + return data; + } + + return null; + } + catch (Exception ex) + { + _logService?.Debug($"Type 4 read exception: {ex.Message}"); + return null; + } + } + + private byte[]? TryReadNdefDirect(ICardReader reader) + { + try + { + // Try reading from common NDEF file locations + var selectFile = new byte[] { 0x00, 0xA4, 0x00, 0x0C, 0x02, 0xE1, 0x04 }; + var response = TransmitApdu(reader, selectFile, "Direct select NDEF"); + + if (IsSuccess(response)) + { + var readData = new byte[] { 0x00, 0xB0, 0x00, 0x00, 0xF0 }; + response = TransmitApdu(reader, readData, "Direct read data"); + + if (response != null && response.Length > 2) + { + var data = new byte[response.Length - 2]; + Array.Copy(response, data, data.Length); + return data; + } + } + + return null; + } + catch (Exception ex) + { + _logService?.Debug($"Direct read exception: {ex.Message}"); + return null; + } + } + + private byte[]? TryReadType2Tag(ICardReader reader) + { + try + { + // Type 2 tags use direct memory read commands + // Read blocks starting from block 4 (where NDEF usually starts) + _logService?.Debug("Attempting Type 2 tag read (direct memory access)"); + + var allData = new List(); + int blockSize = 4; // Default NTAG/MIFARE Ultralight block size + bool blockSizeDetected = false; + + // Read first few blocks to get NDEF length + for (byte block = 4; block < 64;) + { + var readBlock = new byte[] { 0xFF, 0xB0, 0x00, block, 0x10 }; + var response = TransmitApdu(reader, readBlock, $"Read block {block}"); + + if (response == null || response.Length < 2) + { + break; + } + + if (!IsSuccess(response)) + { + // Try alternative command + readBlock = new byte[] { 0x30, block }; + response = TransmitApdu(reader, readBlock, $"Read block {block} (alt)"); + + if (response == null || !IsSuccess(response)) + { + break; + } + } + + // Detect block size from first successful read + var dataLength = response.Length - 2; // Exclude SW1 SW2 + if (!blockSizeDetected && dataLength > 0) + { + blockSize = dataLength; + blockSizeDetected = true; + _logService?.Debug($"Detected block size: {blockSize} bytes"); + } + + // Add data (excluding status words) + if (dataLength > 0) + { + for (int i = 0; i < dataLength; i++) + { + allData.Add(response[i]); + } + } + + // Stop if we've hit terminator TLV + if (allData.Count > 0 && allData[allData.Count - 1] == 0xFE) + { + _logService?.Debug("Found NDEF terminator TLV (0xFE)"); + break; + } + + if (allData.Count > 200) + { + _logService?.Debug("Read limit reached (200 bytes)"); + break; + } + + // Advance block pointer based on detected block size + // Type 2 tags have 4-byte blocks, so if we got 16 bytes, we read 4 blocks + block += (byte)(blockSize / 4); + } + + if (allData.Count > 0) + { + _logService?.Debug($"Read {allData.Count} bytes from Type 2 tag"); + return allData.ToArray(); + } + + return null; + } + catch (Exception ex) + { + _logService?.Debug($"Type 2 read exception: {ex.Message}"); + return null; + } + } + + private byte[]? TransmitApdu(ICardReader reader, byte[] apdu, string description) + { + try + { + _logService?.Debug($"TX [{description}]: {BitConverter.ToString(apdu).Replace("-", " ")}"); + + var response = new byte[256]; + var receivedLength = reader.Transmit(apdu, response); + + if (receivedLength > 0) + { + var result = new byte[receivedLength]; + Array.Copy(response, result, receivedLength); + + _logService?.Debug($"RX [{description}]: {BitConverter.ToString(result).Replace("-", " ")}"); + + return result; + } + + _logService?.Debug($"RX [{description}]: No data received"); + return null; + } + catch (Exception ex) + { + _logService?.Debug($"TX/RX [{description}] Exception: {ex.Message}"); + return null; + } + } + + private bool IsSuccess(byte[]? response) + { + if (response == null || response.Length < 2) + return false; + + var sw1 = response[response.Length - 2]; + var sw2 = response[response.Length - 1]; + + return (sw1 == 0x90 && sw2 == 0x00) || sw1 == 0x91; + } + + private void RaiseEvent(Action action) + { + if (_syncContext != null) + { + _syncContext.Post(_ => action(), null); + } + else + { + action(); + } + } + + public void Dispose() + { + _pollTimer?.Dispose(); + } +} + +public class ReaderEventArgs : EventArgs +{ + public string ReaderName { get; } + + public ReaderEventArgs(string readerName) + { + ReaderName = readerName; + } +} + +public class CardEventArgs : EventArgs +{ + public string ReaderName { get; } + public byte[]? CardData { get; } + + public CardEventArgs(string readerName, byte[]? cardData) + { + ReaderName = readerName; + CardData = cardData; + } +} diff --git a/NfcActions/Services/LogService.cs b/NfcActions/Services/LogService.cs new file mode 100644 index 0000000..be97e06 --- /dev/null +++ b/NfcActions/Services/LogService.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Reflection; +using System.Threading; +using System.Windows; + +namespace NfcActions.Services; + +public class LogService +{ + private readonly SynchronizationContext? _syncContext; + private readonly string _logFilePath; + private readonly object _fileLock = new(); + + public ObservableCollection LogEntries { get; } = new(); + + private const int MAX_LOG_ENTRIES = 500; + + public LogService() + { + _syncContext = SynchronizationContext.Current; + + // Create log file in the same directory as the executable + var exePath = Assembly.GetExecutingAssembly().Location; + var exeDir = Path.GetDirectoryName(exePath) ?? Environment.CurrentDirectory; + var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + _logFilePath = Path.Combine(exeDir, $"nfc-actions-debug-{timestamp}.log"); + + // Write initial header + WriteToFile($"=== NFC Actions Debug Log - Started at {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==="); + WriteToFile($"Log file: {_logFilePath}"); + WriteToFile(""); + } + + public void Log(string message, LogLevel level = LogLevel.Info) + { + var entry = new LogEntry + { + Timestamp = DateTime.Now, + Message = message, + Level = level + }; + + // Write to file immediately + WriteToFile($"[{entry.Timestamp:HH:mm:ss.fff}] [{level}] {message}"); + + // Update UI + if (_syncContext != null) + { + _syncContext.Post(_ => AddEntry(entry), null); + } + else + { + Application.Current?.Dispatcher.Invoke(() => AddEntry(entry)); + } + } + + private void WriteToFile(string message) + { + try + { + lock (_fileLock) + { + File.AppendAllText(_logFilePath, message + Environment.NewLine); + } + } + catch + { + // Ignore file write errors + } + } + + private void AddEntry(LogEntry entry) + { + LogEntries.Insert(0, entry); + + // Keep only the last MAX_LOG_ENTRIES + while (LogEntries.Count > MAX_LOG_ENTRIES) + { + LogEntries.RemoveAt(LogEntries.Count - 1); + } + } + + public void Debug(string message) => Log(message, LogLevel.Debug); + public void Info(string message) => Log(message, LogLevel.Info); + public void Warning(string message) => Log(message, LogLevel.Warning); + public void Error(string message) => Log(message, LogLevel.Error); +} + +public class LogEntry +{ + public DateTime Timestamp { get; set; } + public string Message { get; set; } = string.Empty; + public LogLevel Level { get; set; } + + public string FormattedMessage => $"[{Timestamp:HH:mm:ss.fff}] {Message}"; +} + +public enum LogLevel +{ + Debug, + Info, + Warning, + Error +} diff --git a/NfcActions/Services/NdefParser.cs b/NfcActions/Services/NdefParser.cs new file mode 100644 index 0000000..568017c --- /dev/null +++ b/NfcActions/Services/NdefParser.cs @@ -0,0 +1,311 @@ +using System; +using System.Text; + +namespace NfcActions.Services; + +public class NdefRecord +{ + public string Payload { get; set; } = string.Empty; + public bool IsUri { get; set; } +} + +public static class NdefParser +{ + /// + /// Extracts the first NDEF payload from raw card data. + /// Returns the payload as a string, or null if no valid NDEF message found. + /// + public static string? ExtractFirstPayload(byte[] data) + { + var record = ExtractFirstRecord(data); + return record?.Payload; + } + + /// + /// Extracts the first NDEF record from raw card data with type information. + /// Returns an NdefRecord object, or null if no valid NDEF message found. + /// + public static NdefRecord? ExtractFirstRecord(byte[] data) + { + if (data == null || data.Length < 3) + return null; + + try + { + int position = 0; + + // Look for NDEF TLV (Type-Length-Value) structure + // We're looking for TLV type 0x03 which indicates NDEF Message + while (position < data.Length - 2) + { + byte tlvType = data[position]; + + if (tlvType == 0x00) // NULL TLV, skip + { + position++; + continue; + } + + if (tlvType == 0xFE) // Terminator TLV + { + break; + } + + position++; // Move to length byte + + if (position >= data.Length) + break; + + int length; + if (data[position] == 0xFF) // 3-byte length format + { + if (position + 2 >= data.Length) + break; + length = (data[position + 1] << 8) | data[position + 2]; + position += 3; + } + else + { + length = data[position]; + position++; + } + + if (tlvType == 0x03) // NDEF Message TLV + { + if (position + length > data.Length) + break; + + // Parse NDEF message + var record = ParseNdefMessage(data, position, length); + if (record != null) + return record; + } + + position += length; + } + + // If we didn't find TLV structure, try to parse as raw NDEF message + return ParseNdefMessage(data, 0, data.Length); + } + catch + { + return null; + } + } + + private static NdefRecord? ParseNdefMessage(byte[] data, int offset, int length) + { + if (offset + length > data.Length || length < 3) + return null; + + int position = offset; + int endPosition = offset + length; + + while (position < endPosition) + { + if (position >= data.Length) + break; + + byte header = data[position]; + position++; + + bool mb = (header & 0x80) != 0; // Message Begin + bool me = (header & 0x40) != 0; // Message End + bool cf = (header & 0x20) != 0; // Chunk Flag + bool sr = (header & 0x10) != 0; // Short Record + bool il = (header & 0x08) != 0; // ID Length present + byte tnf = (byte)(header & 0x07); // Type Name Format + + if (position >= endPosition) + break; + + int typeLength = data[position]; + position++; + + int typePosition = 0; // Track where type field starts + + if (position >= endPosition) + break; + + int payloadLength; + if (sr) // Short record - 1 byte payload length + { + payloadLength = data[position]; + position++; + } + else // Normal record - 4 byte payload length + { + if (position + 3 >= endPosition) + break; + + payloadLength = (data[position] << 24) | + (data[position + 1] << 16) | + (data[position + 2] << 8) | + data[position + 3]; + position += 4; + } + + int idLength = 0; + if (il) + { + if (position >= endPosition) + break; + idLength = data[position]; + position++; + } + + // Remember type position before skipping + typePosition = position; + + // Read type field to check if it's a URI record + byte[]? typeField = null; + if (typeLength > 0 && position + typeLength <= endPosition) + { + typeField = new byte[typeLength]; + Array.Copy(data, position, typeField, 0, typeLength); + } + + // Skip type + position += typeLength; + + // Skip ID + position += idLength; + + if (position + payloadLength > endPosition) + break; + + // Extract payload + if (payloadLength > 0) + { + byte[] payload = new byte[payloadLength]; + Array.Copy(data, position, payload, 0, payloadLength); + + // Determine if this is a URI record + bool isUri = IsUriRecord(tnf, typeField, payload); + + // Decode payload + string payloadText = DecodeTextPayload(payload); + + return new NdefRecord + { + Payload = payloadText, + IsUri = isUri + }; + } + + position += payloadLength; + + // If this was the first record, return what we found (or null) + break; + } + + return null; + } + + private static bool IsUriRecord(byte tnf, byte[]? typeField, byte[] payload) + { + // TNF Well-Known (0x01) with type "U" is a URI record + if (tnf == 0x01 && typeField != null && typeField.Length == 1 && typeField[0] == 0x55) // 'U' + { + return true; + } + + // TNF Absolute URI (0x03) + if (tnf == 0x03) + { + return true; + } + + // Check if payload starts with URI identifier code (0x00-0x23) + if (payload.Length > 0 && payload[0] <= 0x23) + { + string prefix = GetUriPrefix(payload[0]); + // If we have a recognized URI prefix, it's likely a URI + if (!string.IsNullOrEmpty(prefix) || payload[0] == 0x00) + { + return true; + } + } + + return false; + } + + private static string DecodeTextPayload(byte[] payload) + { + if (payload.Length == 0) + return string.Empty; + + // Check if first byte indicates URI identifier code + if (payload[0] <= 0x23) // URI identifier codes range from 0x00 to 0x23 + { + string prefix = GetUriPrefix(payload[0]); + string uri = Encoding.UTF8.GetString(payload, 1, payload.Length - 1); + return prefix + uri; + } + + // Check if it's a text record (first byte is status byte) + if (payload.Length > 1) + { + byte statusByte = payload[0]; + bool isUtf16 = (statusByte & 0x80) != 0; + int languageCodeLength = statusByte & 0x3F; + + if (languageCodeLength < payload.Length) + { + int textStart = 1 + languageCodeLength; + if (textStart < payload.Length) + { + var encoding = isUtf16 ? Encoding.Unicode : Encoding.UTF8; + return encoding.GetString(payload, textStart, payload.Length - textStart); + } + } + } + + // Default: try UTF8 decoding of entire payload + return Encoding.UTF8.GetString(payload); + } + + private static string GetUriPrefix(byte code) + { + return code switch + { + 0x00 => "", + 0x01 => "http://www.", + 0x02 => "https://www.", + 0x03 => "http://", + 0x04 => "https://", + 0x05 => "tel:", + 0x06 => "mailto:", + 0x07 => "ftp://anonymous:anonymous@", + 0x08 => "ftp://ftp.", + 0x09 => "ftps://", + 0x0A => "sftp://", + 0x0B => "smb://", + 0x0C => "nfs://", + 0x0D => "ftp://", + 0x0E => "dav://", + 0x0F => "news:", + 0x10 => "telnet://", + 0x11 => "imap:", + 0x12 => "rtsp://", + 0x13 => "urn:", + 0x14 => "pop:", + 0x15 => "sip:", + 0x16 => "sips:", + 0x17 => "tftp:", + 0x18 => "btspp://", + 0x19 => "btl2cap://", + 0x1A => "btgoep://", + 0x1B => "tcpobex://", + 0x1C => "irdaobex://", + 0x1D => "file://", + 0x1E => "urn:epc:id:", + 0x1F => "urn:epc:tag:", + 0x20 => "urn:epc:pat:", + 0x21 => "urn:epc:raw:", + 0x22 => "urn:epc:", + 0x23 => "urn:nfc:", + _ => "" + }; + } +} diff --git a/NfcActions/Services/SettingsService.cs b/NfcActions/Services/SettingsService.cs new file mode 100644 index 0000000..6a54091 --- /dev/null +++ b/NfcActions/Services/SettingsService.cs @@ -0,0 +1,53 @@ +using System; +using System.IO; +using System.Text.Json; +using NfcActions.Models; + +namespace NfcActions.Services; + +public class SettingsService +{ + private readonly string _settingsPath; + + public SettingsService() + { + var appDataPath = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + var appFolder = Path.Combine(appDataPath, "NfcActions"); + Directory.CreateDirectory(appFolder); + _settingsPath = Path.Combine(appFolder, "settings.json"); + } + + public AppSettings Load() + { + try + { + if (File.Exists(_settingsPath)) + { + var json = File.ReadAllText(_settingsPath); + return JsonSerializer.Deserialize(json) ?? new AppSettings(); + } + } + catch (Exception) + { + // Failed to load settings, return defaults + } + + return new AppSettings(); + } + + public void Save(AppSettings settings) + { + try + { + var json = JsonSerializer.Serialize(settings, new JsonSerializerOptions + { + WriteIndented = true + }); + File.WriteAllText(_settingsPath, json); + } + catch (Exception) + { + // Failed to save settings + } + } +} diff --git a/NfcActions/ViewModels/MainViewModel.cs b/NfcActions/ViewModels/MainViewModel.cs new file mode 100644 index 0000000..3cd536f --- /dev/null +++ b/NfcActions/ViewModels/MainViewModel.cs @@ -0,0 +1,216 @@ +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using NfcActions.Models; +using NfcActions.Services; + +namespace NfcActions.ViewModels; + +public class MainViewModel : INotifyPropertyChanged +{ + private readonly CardReaderService _cardReaderService; + private readonly SettingsService _settingsService; + private readonly ActionService _actionService; + private readonly LogService _logService; + private AppSettings _settings; + + private bool _copyToClipboard; + private bool _launchUrls; + private bool _typeAsKeyboard; + + public ObservableCollection Readers { get; } = new(); + + public bool CopyToClipboard + { + get => _copyToClipboard; + set + { + if (_copyToClipboard != value) + { + _copyToClipboard = value; + OnPropertyChanged(nameof(CopyToClipboard)); + _settings.CopyToClipboard = value; + _settingsService.Save(_settings); + } + } + } + + public bool LaunchUrls + { + get => _launchUrls; + set + { + if (_launchUrls != value) + { + _launchUrls = value; + OnPropertyChanged(nameof(LaunchUrls)); + _settings.LaunchUrls = value; + _settingsService.Save(_settings); + } + } + } + + public bool TypeAsKeyboard + { + get => _typeAsKeyboard; + set + { + if (_typeAsKeyboard != value) + { + _typeAsKeyboard = value; + OnPropertyChanged(nameof(TypeAsKeyboard)); + _settings.TypeAsKeyboard = value; + _settingsService.Save(_settings); + } + } + } + + public ObservableCollection LogEntries => _logService.LogEntries; + + public MainViewModel( + CardReaderService cardReaderService, + SettingsService settingsService, + ActionService actionService, + LogService logService) + { + _cardReaderService = cardReaderService; + _settingsService = settingsService; + _actionService = actionService; + _logService = logService; + + // Load settings + _settings = _settingsService.Load(); + _copyToClipboard = _settings.CopyToClipboard; + _launchUrls = _settings.LaunchUrls; + _typeAsKeyboard = _settings.TypeAsKeyboard; + + // Set up event handlers + _cardReaderService.ReaderAdded += OnReaderAdded; + _cardReaderService.ReaderRemoved += OnReaderRemoved; + _cardReaderService.CardInserted += OnCardInserted; + + // Set disabled readers from settings + _cardReaderService.SetDisabledReaders(_settings.DisabledReaders); + + // Load current readers + LoadReaders(); + } + + private void LoadReaders() + { + Readers.Clear(); + var readers = _cardReaderService.GetAvailableReaders(); + + foreach (var readerName in readers) + { + var item = new ReaderItem + { + Name = readerName, + IsEnabled = _cardReaderService.IsReaderEnabled(readerName) + }; + + item.PropertyChanged += OnReaderItemPropertyChanged; + Readers.Add(item); + } + } + + private void OnReaderAdded(object? sender, ReaderEventArgs e) + { + var existing = Readers.FirstOrDefault(r => r.Name == e.ReaderName); + if (existing == null) + { + var item = new ReaderItem + { + Name = e.ReaderName, + IsEnabled = _cardReaderService.IsReaderEnabled(e.ReaderName) + }; + + item.PropertyChanged += OnReaderItemPropertyChanged; + Readers.Add(item); + } + } + + private void OnReaderRemoved(object? sender, ReaderEventArgs e) + { + var item = Readers.FirstOrDefault(r => r.Name == e.ReaderName); + if (item != null) + { + item.PropertyChanged -= OnReaderItemPropertyChanged; + Readers.Remove(item); + } + } + + private void OnReaderItemPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (sender is ReaderItem item && e.PropertyName == nameof(ReaderItem.IsEnabled)) + { + if (item.IsEnabled) + { + _cardReaderService.EnableReader(item.Name); + _settings.DisabledReaders.Remove(item.Name); + } + else + { + _cardReaderService.DisableReader(item.Name); + _settings.DisabledReaders.Add(item.Name); + } + + _settingsService.Save(_settings); + } + } + + private void OnCardInserted(object? sender, CardEventArgs e) + { + if (e.CardData == null) + { + _logService.Warning("Card inserted but no data available"); + return; + } + + _logService.Info($"Processing card data ({e.CardData.Length} bytes)"); + + var record = NdefParser.ExtractFirstRecord(e.CardData); + if (record == null || string.IsNullOrEmpty(record.Payload)) + { + _logService.Warning("Failed to extract NDEF payload from card data"); + return; + } + + _logService.Info($"NDEF Payload extracted: {record.Payload}"); + _logService.Info($"Record type: {(record.IsUri ? "URI" : "Text/Other")}"); + + // Perform actions based on settings + if (CopyToClipboard) + { + _logService.Info("Copying to clipboard..."); + _actionService.CopyToClipboard(record.Payload); + _logService.Info("Copied to clipboard successfully"); + } + + if (LaunchUrls) + { + if (record.IsUri) + { + _logService.Info("Attempting to launch URL..."); + _actionService.LaunchUrl(record.Payload); + } + else + { + _logService.Info("Skipping browser launch - record is not a URI"); + } + } + + if (TypeAsKeyboard) + { + _logService.Info("Typing as keyboard input..."); + _actionService.TypeText(record.Payload); + } + } + + public event PropertyChangedEventHandler? PropertyChanged; + + protected virtual void OnPropertyChanged(string propertyName) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed2ab8b --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +# NFC Actions + +A Windows 11 system tray application that monitors NFC card readers and performs automated actions when NFC tags are detected. + +## Features + +- **System Tray Application**: Runs in the background with a system tray icon +- **Multi-Reader Support**: Automatically detects and monitors all connected PC/SC-compatible NFC readers +- **Dynamic USB Detection**: Automatically handles USB reader plug/unplug events +- **Reader Selection**: Enable/disable monitoring for specific readers +- **NDEF Payload Extraction**: Reads NDEF data from NFC tags +- **Configurable Actions**: + - Copy NDEF payload to clipboard + - Launch URLs in default browser + - Type NDEF content as keyboard input +- **Persistent Settings**: Remembers reader preferences and action settings between sessions + +## Requirements + +- Windows 11 (or Windows 10 with PC/SC service) +- .NET 7.0 Runtime or later +- PC/SC-compatible NFC reader + +## Building the Application + +### Using Visual Studio 2022 + +1. Open `NfcActions.sln` in Visual Studio 2022 +2. Build the solution (Ctrl+Shift+B) +3. Run the application (F5) + +### Using .NET CLI + +```bash +dotnet build NfcActions.sln +dotnet run --project NfcActions/NfcActions.csproj +``` + +## Usage + +### Starting the Application + +When you run NFC Actions, it will: +1. Start minimized to the system tray +2. Show a notification balloon indicating it's running +3. Automatically detect all connected NFC readers +4. Begin monitoring enabled readers for card events + +### Accessing the Main Window + +- **Double-click** the tray icon, OR +- **Right-click** the tray icon and select "Open" + +### Configuring Readers + +In the main window: +1. View all detected NFC readers in the "Active Readers" list +2. Check/uncheck readers to enable/disable monitoring +3. Settings are saved automatically + +### Configuring Actions + +Select which actions to perform when an NFC tag is detected: + +- **Copy NDEF data to clipboard**: Copies the NDEF payload text to the clipboard +- **Launch URLs in default browser**: If the NDEF payload is a URL, opens it in your default browser +- **Type NDEF content as keyboard input**: Simulates typing the NDEF payload (useful for form filling) + +**Note**: Only the payload from the first NDEF record is used. Tags with multiple messages/records will use the first one. + +### Exiting the Application + +- Right-click the tray icon and select "Exit" + +## Settings + +Settings are stored in: +``` +%APPDATA%\NfcActions\settings.json +``` + +The settings file includes: +- List of disabled readers +- Action preferences (clipboard, URLs, keyboard) + +## Project Structure + +``` +NfcActions/ +β”œβ”€β”€ Models/ +β”‚ β”œβ”€β”€ AppSettings.cs # Application settings model +β”‚ └── ReaderItem.cs # Reader list item model +β”œβ”€β”€ Services/ +β”‚ β”œβ”€β”€ ActionService.cs # Handles clipboard, browser, and keyboard actions +β”‚ β”œβ”€β”€ CardReaderService.cs # PC/SC reader monitoring and card detection +β”‚ β”œβ”€β”€ NdefParser.cs # NDEF message parsing +β”‚ └── SettingsService.cs # Settings persistence +β”œβ”€β”€ ViewModels/ +β”‚ └── MainViewModel.cs # Main window view model +β”œβ”€β”€ App.xaml # Application resources and startup +β”œβ”€β”€ App.xaml.cs # Application lifecycle and tray icon +β”œβ”€β”€ MainWindow.xaml # Main window UI +└── MainWindow.xaml.cs # Main window code-behind +``` + +## Dependencies + +- **PCSC** (v7.0.1): PC/SC wrapper for smart card communication +- **PCSC.Iso7816** (v7.0.1): ISO 7816 APDU commands +- **InputSimulatorCore** (v1.0.5): Keyboard input simulation +- **System.Management** (v9.0.10): USB device detection (via WMI) + +## Technical Details + +### NDEF Parsing + +The application reads NDEF (NFC Data Exchange Format) messages from NFC tags using standard ISO 7816 APDUs: +- Selects the NDEF Tag Application +- Reads the Capability Container +- Reads the NDEF file +- Parses the NDEF message structure to extract the payload + +### Reader Monitoring + +The application polls PC/SC readers every 500ms to detect: +- New readers being connected +- Readers being disconnected +- Cards being inserted +- Cards being removed + +### Thread Safety + +All PC/SC operations are thread-safe and properly synchronized to handle concurrent reader access. + +## Troubleshooting + +### No Readers Detected + +- Ensure your NFC reader is properly connected +- Check that the Windows Smart Card service is running: + ``` + services.msc β†’ Smart Card + ``` +- Try unplugging and replugging the reader + +### Card Not Detected + +- Ensure the reader is enabled in the main window +- Try holding the card on the reader for a longer duration +- Some readers require specific card positioning + +### Actions Not Working + +- **Clipboard**: Ensure no other application has locked the clipboard +- **Browser**: Check that you have a default browser configured +- **Keyboard**: The keyboard simulation requires the target window to have focus + +## License + +This project is provided as-is for educational and development purposes. + +## Contributing + +Feel free to submit issues or pull requests for improvements or bug fixes.