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:
105
.gitignore
vendored
Normal file
105
.gitignore
vendored
Normal file
@@ -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
|
||||
22
NfcActions.sln
Normal file
22
NfcActions.sln
Normal file
@@ -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
|
||||
10
NfcActions/App.xaml
Normal file
10
NfcActions/App.xaml
Normal 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
111
NfcActions/App.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
10
NfcActions/AssemblyInfo.cs
Normal file
10
NfcActions/AssemblyInfo.cs
Normal 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
149
NfcActions/MainWindow.xaml
Normal 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>
|
||||
42
NfcActions/MainWindow.xaml.cs
Normal file
42
NfcActions/MainWindow.xaml.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
11
NfcActions/Models/AppSettings.cs
Normal file
11
NfcActions/Models/AppSettings.cs
Normal 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;
|
||||
}
|
||||
30
NfcActions/Models/ReaderItem.cs
Normal file
30
NfcActions/Models/ReaderItem.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
28
NfcActions/NfcActions.csproj
Normal file
28
NfcActions/NfcActions.csproj
Normal 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>
|
||||
BIN
NfcActions/Resources/icon.ico
Normal file
BIN
NfcActions/Resources/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
NfcActions/Resources/icon.png
Normal file
BIN
NfcActions/Resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
NfcActions/Resources/logo.png
Normal file
BIN
NfcActions/Resources/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
69
NfcActions/Services/ActionService.cs
Normal file
69
NfcActions/Services/ActionService.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
544
NfcActions/Services/CardReaderService.cs
Normal file
544
NfcActions/Services/CardReaderService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
106
NfcActions/Services/LogService.cs
Normal file
106
NfcActions/Services/LogService.cs
Normal 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
|
||||
}
|
||||
311
NfcActions/Services/NdefParser.cs
Normal file
311
NfcActions/Services/NdefParser.cs
Normal 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:",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
}
|
||||
53
NfcActions/Services/SettingsService.cs
Normal file
53
NfcActions/Services/SettingsService.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
216
NfcActions/ViewModels/MainViewModel.cs
Normal file
216
NfcActions/ViewModels/MainViewModel.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
164
README.md
Normal file
164
README.md
Normal file
@@ -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.
|
||||
Reference in New Issue
Block a user