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 <noreply@anthropic.com>
This commit is contained in:
2025-11-06 22:56:11 -08:00
commit 5b6a7f0fa5
20 changed files with 1981 additions and 0 deletions

10
NfcActions/App.xaml Normal file
View File

@@ -0,0 +1,10 @@
<Application x:Class="NfcActions.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:NfcActions"
Startup="Application_Startup"
Exit="Application_Exit">
<Application.Resources>
</Application.Resources>
</Application>

111
NfcActions/App.xaml.cs Normal file
View File

@@ -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;
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
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();
}
}

View File

@@ -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)
)]

149
NfcActions/MainWindow.xaml Normal file
View File

@@ -0,0 +1,149 @@
<Window x:Class="NfcActions.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:NfcActions"
mc:Ignorable="d"
Title="NFC Actions" Height="700" Width="800"
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize"
Closing="Window_Closing">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Header -->
<Grid Grid.Row="0" Margin="0,0,0,15">
<TextBlock Text="NFC Actions Configuration"
FontSize="20"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Left"/>
<Image Source="Resources/logo.png"
Height="60"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Margin="0,0,15,0"
Cursor="Hand"
MouseLeftButtonDown="Logo_Click"
ToolTip="Visit dangerousthings.com"/>
</Grid>
<!-- Readers List -->
<GroupBox Grid.Row="1" Header="Active Readers" Margin="0,0,0,15">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ListBox Grid.Row="0"
ItemsSource="{Binding Readers}"
Margin="0,0,0,5"
MaxHeight="100">
<ListBox.ItemTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding IsEnabled}"
Content="{Binding Name}"
Margin="2"
Padding="3,2"/>
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="0"/>
<Setter Property="Margin" Value="0"/>
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
<TextBlock Grid.Row="1"
Text="Check/uncheck readers to enable/disable monitoring. Settings are saved automatically."
FontSize="11"
Foreground="Gray"
TextWrapping="Wrap"/>
</Grid>
</GroupBox>
<!-- Actions -->
<GroupBox Grid.Row="2" Header="Actions to Perform on Card Detection" Margin="0,0,0,15">
<StackPanel Margin="10">
<CheckBox IsChecked="{Binding CopyToClipboard}"
Content="Copy NDEF data to clipboard"
Margin="0,3"/>
<CheckBox IsChecked="{Binding LaunchUrls}"
Content="Launch URLs in default browser"
Margin="0,3"/>
<CheckBox IsChecked="{Binding TypeAsKeyboard}"
Content="Type NDEF content as keyboard input"
Margin="0,3"/>
<TextBlock Text="Note: Only the payload from the first NDEF record will be used."
FontSize="11"
Foreground="Gray"
Margin="0,5,0,0"
TextWrapping="Wrap"/>
</StackPanel>
</GroupBox>
<!-- Activity Log -->
<GroupBox Grid.Row="3" Header="Activity Log">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<ListBox Grid.Row="0"
ItemsSource="{Binding LogEntries}"
Margin="0,0,0,5"
ScrollViewer.HorizontalScrollBarVisibility="Auto"
ScrollViewer.VerticalScrollBarVisibility="Auto">
<ListBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding FormattedMessage}"
FontFamily="Consolas"
FontSize="11"
TextWrapping="Wrap">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding Level}" Value="Error">
<Setter Property="Foreground" Value="Red"/>
</DataTrigger>
<DataTrigger Binding="{Binding Level}" Value="Warning">
<Setter Property="Foreground" Value="Orange"/>
</DataTrigger>
<DataTrigger Binding="{Binding Level}" Value="Debug">
<Setter Property="Foreground" Value="Gray"/>
</DataTrigger>
<DataTrigger Binding="{Binding Level}" Value="Info">
<Setter Property="Foreground" Value="Black"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="2,1"/>
<Setter Property="Margin" Value="0"/>
</Style>
</ListBox.ItemContainerStyle>
</ListBox>
<TextBlock Grid.Row="1"
Text="Real-time activity log showing reader events, card detection, and NDEF processing."
FontSize="11"
Foreground="Gray"
TextWrapping="Wrap"/>
</Grid>
</GroupBox>
</Grid>
</Window>

View File

@@ -0,0 +1,42 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Windows;
using System.Windows.Input;
using NfcActions.ViewModels;
namespace NfcActions;
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
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
}
}
}

View File

@@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace NfcActions.Models;
public class AppSettings
{
public HashSet<string> DisabledReaders { get; set; } = new();
public bool CopyToClipboard { get; set; } = false;
public bool LaunchUrls { get; set; } = true;
public bool TypeAsKeyboard { get; set; } = false;
}

View File

@@ -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));
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net7.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon>Resources\icon.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="InputSimulatorCore" Version="1.0.5" />
<PackageReference Include="PCSC" Version="7.0.1" />
<PackageReference Include="PCSC.Iso7816" Version="7.0.1" />
<PackageReference Include="System.Management" Version="9.0.10" />
</ItemGroup>
<ItemGroup>
<Resource Include="Resources\*.png" />
<Resource Include="Resources\*.ico" />
</ItemGroup>
<ItemGroup>
<None Include="Resources\icon.ico" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -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
}
}
}

View File

@@ -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<string, bool> _readerCardPresent = new();
private readonly HashSet<string> _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<ReaderEventArgs>? ReaderAdded;
public event EventHandler<ReaderEventArgs>? ReaderRemoved;
public event EventHandler<CardEventArgs>? CardInserted;
public event EventHandler<CardEventArgs>? 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<string>();
// 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<string> GetAvailableReaders()
{
try
{
using var context = ContextFactory.Instance.Establish(SCardScope.System);
return context.GetReaders()?.ToList() ?? new List<string>();
}
catch
{
return new List<string>();
}
}
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<string> 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<byte>();
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;
}
}

View File

@@ -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<LogEntry> 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
}

View File

@@ -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
{
/// <summary>
/// Extracts the first NDEF payload from raw card data.
/// Returns the payload as a string, or null if no valid NDEF message found.
/// </summary>
public static string? ExtractFirstPayload(byte[] data)
{
var record = ExtractFirstRecord(data);
return record?.Payload;
}
/// <summary>
/// Extracts the first NDEF record from raw card data with type information.
/// Returns an NdefRecord object, or null if no valid NDEF message found.
/// </summary>
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:",
_ => ""
};
}
}

View File

@@ -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<AppSettings>(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
}
}
}

View File

@@ -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<ReaderItem> 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<LogEntry> 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));
}
}