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:
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user