Initial commit: LAAPC Print Service - Windows service with multi-tray printing, IPC queue management, PDF testing support, and self-installing CLI

This commit is contained in:
Jason
2026-05-14 16:59:59 -05:00
commit cce9248089
23 changed files with 2974 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
using Microsoft.Extensions.Options;
using PrintService.Models;
using System.Drawing;
using System.Drawing.Printing;
using System.Text.RegularExpressions;
namespace PrintService.Services;
/// <summary>
/// Processes documents and renders them for printing
/// </summary>
public class DocumentProcessor
{
private readonly AppSettings _settings;
private readonly ILogger<DocumentProcessor> _logger;
public DocumentProcessor(IOptions<AppSettings> settings, ILogger<DocumentProcessor> logger)
{
_settings = settings.Value;
_logger = logger;
}
/// <summary>
/// Process and transform document content
/// </summary>
public string ProcessDocument(string content, DocumentConfig config)
{
var processed = content;
// Apply text transformations
foreach (var transform in config.Transformations)
{
try
{
processed = Regex.Replace(processed, transform.Pattern, transform.Replacement);
if (_settings.VerboseLogging)
{
_logger.LogDebug("Applied transformation: {Description}", transform.Description);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to apply transformation: {Pattern}", transform.Pattern);
}
}
return processed;
}
/// <summary>
/// Get document configuration by type
/// </summary>
public DocumentConfig? GetDocumentConfig(string documentType)
{
return _settings.DocumentTypes.FirstOrDefault(dt =>
dt.Name.Equals(documentType, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Read file content
/// </summary>
public string ReadFile(string filePath)
{
return File.ReadAllText(filePath);
}
/// <summary>
/// Archive or delete file after processing
/// </summary>
public void PostProcess(PrintJob job, DocumentConfig config)
{
try
{
// Skip post-processing if configured (useful for testing)
if (config.SkipPostProcessing)
{
_logger.LogInformation("Skipping post-processing for job {JobId} (file remains in queue)", job.Id);
return;
}
if (config.ArchiveAfterPrint && !string.IsNullOrEmpty(config.ArchivePath))
{
Directory.CreateDirectory(config.ArchivePath);
var archiveFileName = Path.Combine(config.ArchivePath,
$"{DateTime.Now:yyyyMMdd_HHmmss}_{job.OriginalFilename}");
if (File.Exists(job.QueuedFilePath))
{
File.Move(job.QueuedFilePath, archiveFileName);
_logger.LogInformation("Archived job {JobId} to {Path}", job.Id, archiveFileName);
}
}
else
{
if (File.Exists(job.QueuedFilePath))
{
File.Delete(job.QueuedFilePath);
_logger.LogInformation("Deleted file for job {JobId}", job.Id);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to post-process job {JobId}", job.Id);
}
}
}
+205
View File
@@ -0,0 +1,205 @@
using Microsoft.Extensions.Options;
using PrintService.Models;
namespace PrintService.Services;
/// <summary>
/// Monitors the Captures folder for new files and moves them to queue
/// </summary>
public class FileMonitorService : IDisposable
{
private readonly AppSettings _settings;
private readonly PrintQueueService _queueService;
private readonly ILogger<FileMonitorService> _logger;
private FileSystemWatcher? _watcher;
private readonly Dictionary<string, DateTime> _pendingFiles = new();
private readonly Timer _debounceTimer;
private readonly object _lock = new();
public FileMonitorService(
IOptions<AppSettings> settings,
PrintQueueService queueService,
ILogger<FileMonitorService> logger)
{
_settings = settings.Value;
_queueService = queueService;
_logger = logger;
// Timer for debouncing file system events
_debounceTimer = new Timer(ProcessPendingFiles, null,
TimeSpan.FromMilliseconds(_settings.DebounceDelayMs),
TimeSpan.FromMilliseconds(_settings.DebounceDelayMs));
}
/// <summary>
/// Start monitoring the Captures folder
/// </summary>
public void Start()
{
if (!Directory.Exists(_settings.CapturesPath))
{
Directory.CreateDirectory(_settings.CapturesPath);
_logger.LogInformation("Created captures directory: {Path}", _settings.CapturesPath);
}
_watcher = new FileSystemWatcher(_settings.CapturesPath)
{
Filter = "*.TXT",
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime,
EnableRaisingEvents = true
};
_watcher.Created += OnFileCreated;
_watcher.Changed += OnFileChanged;
_logger.LogInformation("Started monitoring: {Path}", _settings.CapturesPath);
}
/// <summary>
/// Stop monitoring
/// </summary>
public void Stop()
{
if (_watcher != null)
{
_watcher.EnableRaisingEvents = false;
_watcher.Dispose();
_watcher = null;
}
_logger.LogInformation("Stopped monitoring");
}
private void OnFileCreated(object sender, FileSystemEventArgs e)
{
lock (_lock)
{
_pendingFiles[e.FullPath] = DateTime.Now;
if (_settings.VerboseLogging)
{
_logger.LogDebug("File created: {Path}", e.FullPath);
}
}
}
private void OnFileChanged(object sender, FileSystemEventArgs e)
{
lock (_lock)
{
_pendingFiles[e.FullPath] = DateTime.Now;
if (_settings.VerboseLogging)
{
_logger.LogDebug("File changed: {Path}", e.FullPath);
}
}
}
/// <summary>
/// Process files after debounce delay
/// </summary>
private void ProcessPendingFiles(object? state)
{
List<string> filesToProcess;
lock (_lock)
{
var cutoff = DateTime.Now.AddMilliseconds(-_settings.DebounceDelayMs);
filesToProcess = _pendingFiles
.Where(kvp => kvp.Value <= cutoff)
.Select(kvp => kvp.Key)
.ToList();
foreach (var file in filesToProcess)
{
_pendingFiles.Remove(file);
}
}
foreach (var filePath in filesToProcess)
{
ProcessFile(filePath);
}
}
/// <summary>
/// Process a single file: move to queue with GUID name
/// </summary>
private void ProcessFile(string filePath)
{
try
{
if (!File.Exists(filePath))
{
return; // File may have been moved or deleted
}
var fileName = Path.GetFileName(filePath);
var jobId = Guid.NewGuid();
var queuedFileName = $"{jobId}.txt";
var queuedPath = Path.Combine(_settings.QueuePath, queuedFileName);
// Move file to queue with GUID name to prevent collisions
File.Move(filePath, queuedPath);
_logger.LogInformation("Moved {FileName} to queue as {QueuedFile}", fileName, queuedFileName);
// Note: Job will be created when CLI sends command with document type
// For now, we just have the file safely in the queue folder
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process file: {Path}", filePath);
}
}
/// <summary>
/// Manually process a file (called from IPC when CLI provides metadata)
/// </summary>
public PrintJob CreateJobFromFile(string filePath, string documentType, string? orderNumber)
{
var fileName = Path.GetFileName(filePath);
var jobId = Guid.NewGuid();
var queuedFileName = $"{jobId}.txt";
var queuedPath = Path.Combine(_settings.QueuePath, queuedFileName);
// Check if this document type has SkipPostProcessing enabled
var config = _settings.DocumentTypes.FirstOrDefault(dt =>
dt.Name.Equals(documentType, StringComparison.OrdinalIgnoreCase));
if (config?.SkipPostProcessing == true)
{
// Copy instead of move for testing scenarios
File.Copy(filePath, queuedPath, overwrite: true);
_logger.LogInformation("Copied {FileName} to queue (test mode - source file preserved)", fileName);
}
else
{
// Move file to queue (normal operation)
File.Move(filePath, queuedPath, overwrite: true);
_logger.LogInformation("Moved {FileName} to queue", fileName);
}
var job = new PrintJob
{
Id = jobId,
SourceFilePath = filePath,
QueuedFilePath = queuedPath,
DocumentType = documentType,
OrderNumber = orderNumber,
OriginalFilename = fileName,
Status = PrintJobStatus.Pending,
CreatedAt = DateTime.Now,
UpdatedAt = DateTime.Now
};
_logger.LogInformation("Created job {JobId} for {FileName} (type: {DocType})",
job.Id, fileName, documentType);
return job;
}
public void Dispose()
{
_debounceTimer?.Dispose();
Stop();
}
}
+241
View File
@@ -0,0 +1,241 @@
using Microsoft.Extensions.Options;
using PrintService.Models;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
namespace PrintService.Services;
/// <summary>
/// Named pipe server for IPC communication with CLI
/// </summary>
public class IpcService : IDisposable
{
private readonly AppSettings _settings;
private readonly PrintQueueService _queueService;
private readonly FileMonitorService _fileMonitorService;
private readonly ILogger<IpcService> _logger;
private CancellationTokenSource? _cancellationTokenSource;
private Task? _listenerTask;
public IpcService(
IOptions<AppSettings> settings,
PrintQueueService queueService,
FileMonitorService fileMonitorService,
ILogger<IpcService> logger)
{
_settings = settings.Value;
_queueService = queueService;
_fileMonitorService = fileMonitorService;
_logger = logger;
}
/// <summary>
/// Start listening for IPC commands
/// </summary>
public void Start()
{
_cancellationTokenSource = new CancellationTokenSource();
_listenerTask = Task.Run(() => ListenForConnections(_cancellationTokenSource.Token));
_logger.LogInformation("IPC service started on pipe: {PipeName}", _settings.PipeName);
}
/// <summary>
/// Stop listening
/// </summary>
public void Stop()
{
_cancellationTokenSource?.Cancel();
_listenerTask?.Wait(TimeSpan.FromSeconds(5));
_logger.LogInformation("IPC service stopped");
}
/// <summary>
/// Listen for incoming connections
/// </summary>
private async Task ListenForConnections(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
NamedPipeServerStream? pipeServer = null;
try
{
pipeServer = new NamedPipeServerStream(
_settings.PipeName,
PipeDirection.InOut,
NamedPipeServerStream.MaxAllowedServerInstances,
PipeTransmissionMode.Message,
PipeOptions.Asynchronous);
await pipeServer.WaitForConnectionAsync(cancellationToken);
// Handle client in separate task, passing ownership of pipeServer
var serverToHandle = pipeServer;
pipeServer = null; // Clear reference so we don't dispose it in catch
_ = Task.Run(async () =>
{
using (serverToHandle)
{
await HandleClient(serverToHandle);
}
}, cancellationToken);
}
catch (OperationCanceledException)
{
pipeServer?.Dispose();
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in IPC listener");
pipeServer?.Dispose();
await Task.Delay(1000, cancellationToken);
}
}
}
/// <summary>
/// Handle a client connection
/// </summary>
private async Task HandleClient(NamedPipeServerStream pipeServer)
{
try
{
if (!pipeServer.IsConnected)
{
return;
}
// Read command
var buffer = new byte[4096];
var bytesRead = await pipeServer.ReadAsync(buffer, 0, buffer.Length);
if (bytesRead == 0)
{
return; // Client disconnected
}
var message = Encoding.UTF8.GetString(buffer, 0, bytesRead);
if (_settings.VerboseLogging)
{
_logger.LogDebug("Received IPC message: {Message}", message);
}
var command = JsonSerializer.Deserialize<PrintCommand>(message);
if (command == null)
{
await SendResponse(pipeServer, false, "Invalid command format");
return;
}
// Process command
var result = ProcessCommand(command);
await SendResponse(pipeServer, result.Success, result.Message);
}
catch (ObjectDisposedException)
{
// Client disconnected, this is normal
if (_settings.VerboseLogging)
{
_logger.LogDebug("Client disconnected");
}
}
catch (IOException ex)
{
// Pipe communication error
if (_settings.VerboseLogging)
{
_logger.LogDebug(ex, "Pipe communication error");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error handling IPC client");
try
{
if (pipeServer.IsConnected)
{
await SendResponse(pipeServer, false, $"Error: {ex.Message}");
}
}
catch { }
}
}
/// <summary>
/// Process a print command
/// </summary>
private (bool Success, string Message) ProcessCommand(PrintCommand command)
{
try
{
if (string.IsNullOrEmpty(command.FilePath) || !File.Exists(command.FilePath))
{
return (false, $"File not found: {command.FilePath}");
}
if (string.IsNullOrEmpty(command.DocumentType))
{
return (false, "Document type is required");
}
// Create job and enqueue
var job = _fileMonitorService.CreateJobFromFile(
command.FilePath,
command.DocumentType,
command.OrderNumber);
_queueService.Enqueue(job);
return (true, $"Job {job.Id} queued successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process command");
return (false, ex.Message);
}
}
/// <summary>
/// Send response back to client
/// </summary>
private async Task SendResponse(NamedPipeServerStream pipeServer, bool success, string message)
{
var response = new PrintResponse
{
Success = success,
Message = message
};
var json = JsonSerializer.Serialize(response);
var bytes = Encoding.UTF8.GetBytes(json);
await pipeServer.WriteAsync(bytes, 0, bytes.Length);
await pipeServer.FlushAsync();
}
public void Dispose()
{
Stop();
_cancellationTokenSource?.Dispose();
}
}
/// <summary>
/// Command sent from CLI to service
/// </summary>
public class PrintCommand
{
public string FilePath { get; set; } = string.Empty;
public string DocumentType { get; set; } = string.Empty;
public string? OrderNumber { get; set; }
}
/// <summary>
/// Response from service to CLI
/// </summary>
public class PrintResponse
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
}
+167
View File
@@ -0,0 +1,167 @@
using Microsoft.Extensions.Options;
using PrintService.Models;
using System.Collections.Concurrent;
using System.Text.Json;
namespace PrintService.Services;
/// <summary>
/// Manages the print job queue with persistence
/// </summary>
public class PrintQueueService
{
private readonly ConcurrentQueue<PrintJob> _queue = new();
private readonly AppSettings _settings;
private readonly ILogger<PrintQueueService> _logger;
private readonly SemaphoreSlim _semaphore = new(1, 1);
public PrintQueueService(IOptions<AppSettings> settings, ILogger<PrintQueueService> logger)
{
_settings = settings.Value;
_logger = logger;
LoadQueueState();
}
/// <summary>
/// Enqueue a new print job
/// </summary>
public void Enqueue(PrintJob job)
{
_queue.Enqueue(job);
_logger.LogInformation("Job {JobId} enqueued for document type: {DocType}", job.Id, job.DocumentType);
SaveQueueState();
}
/// <summary>
/// Try to dequeue the next pending job
/// </summary>
public bool TryDequeue(out PrintJob? job)
{
bool result = _queue.TryDequeue(out job);
if (result && job != null)
{
_logger.LogInformation("Job {JobId} dequeued", job.Id);
SaveQueueState();
}
return result;
}
/// <summary>
/// Peek at the next job without removing it
/// </summary>
public bool TryPeek(out PrintJob? job)
{
return _queue.TryPeek(out job);
}
/// <summary>
/// Get count of jobs in queue
/// </summary>
public int Count => _queue.Count;
/// <summary>
/// Save queue state to disk
/// </summary>
private void SaveQueueState()
{
try
{
_semaphore.Wait();
var jobs = _queue.ToArray();
var json = JsonSerializer.Serialize(jobs, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_settings.QueueStateFile, json);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save queue state");
}
finally
{
_semaphore.Release();
}
}
/// <summary>
/// Load queue state from disk
/// </summary>
private void LoadQueueState()
{
try
{
if (File.Exists(_settings.QueueStateFile))
{
var json = File.ReadAllText(_settings.QueueStateFile);
var jobs = JsonSerializer.Deserialize<PrintJob[]>(json);
if (jobs != null)
{
foreach (var job in jobs)
{
// Only reload pending or retry-scheduled jobs
if (job.Status == PrintJobStatus.Pending || job.Status == PrintJobStatus.RetryScheduled)
{
_queue.Enqueue(job);
}
}
_logger.LogInformation("Loaded {Count} jobs from queue state", _queue.Count);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load queue state");
}
}
/// <summary>
/// Requeue a job (for retry)
/// </summary>
public void Requeue(PrintJob job)
{
job.RetryCount++;
job.Status = PrintJobStatus.RetryScheduled;
job.UpdatedAt = DateTime.Now;
if (job.RetryCount >= _settings.MaxRetryAttempts)
{
_logger.LogWarning("Job {JobId} exceeded max retries, moving to error folder", job.Id);
MoveToErrorFolder(job);
}
else
{
_logger.LogInformation("Requeueing job {JobId}, attempt {Retry}", job.Id, job.RetryCount);
_queue.Enqueue(job);
SaveQueueState();
}
}
/// <summary>
/// Move a failed job to error folder
/// </summary>
private void MoveToErrorFolder(PrintJob job)
{
try
{
var errorFileName = Path.Combine(_settings.ErrorPath,
$"{Path.GetFileNameWithoutExtension(job.OriginalFilename)}_{job.Id}.txt");
if (File.Exists(job.QueuedFilePath))
{
File.Move(job.QueuedFilePath, errorFileName);
}
// Save error details
var errorInfo = Path.Combine(_settings.ErrorPath,
$"{Path.GetFileNameWithoutExtension(job.OriginalFilename)}_{job.Id}_error.json");
var json = JsonSerializer.Serialize(job, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(errorInfo, json);
job.Status = PrintJobStatus.Failed;
SaveQueueState();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to move job {JobId} to error folder", job.Id);
}
}
}
+176
View File
@@ -0,0 +1,176 @@
using Microsoft.Extensions.Options;
using PrintService.Models;
using System.Drawing;
using System.Drawing.Printing;
namespace PrintService.Services;
/// <summary>
/// Handles printing to Windows print queue with tray control
/// </summary>
public class PrinterService
{
private readonly AppSettings _settings;
private readonly ILogger<PrinterService> _logger;
public PrinterService(IOptions<AppSettings> settings, ILogger<PrinterService> logger)
{
_settings = settings.Value;
_logger = logger;
}
/// <summary>
/// Print document to specified printer with tray sequence
/// </summary>
public void Print(string content, DocumentConfig config, string originalFileName)
{
if (config.TraySequence.Length == 0)
{
throw new InvalidOperationException("No tray sequence defined for document type");
}
var lines = content.Split(new[] { '\r', '\n' }, StringSplitOptions.None);
var linesPerPage = CalculateLinesPerPage(config);
var currentPageIndex = 0;
var printDoc = new PrintDocument
{
PrinterSettings = { PrinterName = config.PrinterName }
};
// Handle PDF output if using Microsoft Print to PDF
if (config.PrinterName.Equals("Microsoft Print to PDF", StringComparison.OrdinalIgnoreCase) &&
!string.IsNullOrEmpty(config.OutputPath))
{
Directory.CreateDirectory(config.OutputPath);
var pdfFileName = Path.GetFileNameWithoutExtension(originalFileName) + ".pdf";
var pdfPath = Path.Combine(config.OutputPath, pdfFileName);
printDoc.PrinterSettings.PrintToFile = true;
printDoc.PrinterSettings.PrintFileName = pdfPath;
_logger.LogInformation("PDF will be saved to: {Path}", pdfPath);
}
// Verify printer exists
if (!PrinterSettings.InstalledPrinters.Cast<string>().Contains(config.PrinterName))
{
throw new InvalidOperationException($"Printer '{config.PrinterName}' not found");
}
printDoc.PrintPage += (sender, e) =>
{
if (e.Graphics == null || e.PageSettings == null)
return;
// Select tray for current page
var trayIndex = config.TraySequence[currentPageIndex % config.TraySequence.Length];
try
{
// Map logical tray number to PaperSource
var paperSource = GetPaperSource(printDoc.PrinterSettings, trayIndex);
if (paperSource != null)
{
e.PageSettings.PaperSource = paperSource;
if (_settings.VerboseLogging)
{
_logger.LogDebug("Page {Page}: Using tray {Tray} ({Source})",
currentPageIndex + 1, trayIndex, paperSource.SourceName);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to set tray {Tray}, using default", trayIndex);
}
// Render page content
RenderPage(e.Graphics, lines, currentPageIndex * linesPerPage, linesPerPage, config);
currentPageIndex++;
e.HasMorePages = (currentPageIndex < config.TraySequence.Length);
};
_logger.LogInformation("Printing to {Printer} with {Pages} pages",
config.PrinterName, config.TraySequence.Length);
printDoc.Print();
}
/// <summary>
/// Render page content using Graphics API
/// </summary>
private void RenderPage(Graphics graphics, string[] lines, int startLine, int linesPerPage, DocumentConfig config)
{
var font = new Font(config.FontName, config.FontSize);
var brush = Brushes.Black;
var lineHeight = font.GetHeight(graphics);
var x = (float)config.HorizontalOffset;
var y = (float)config.VerticalOffset;
var endLine = Math.Min(startLine + linesPerPage, lines.Length);
for (int i = startLine; i < endLine; i++)
{
if (i < lines.Length)
{
graphics.DrawString(lines[i], font, brush, x, y);
y += lineHeight;
}
}
}
/// <summary>
/// Calculate lines per page based on font and page size
/// </summary>
private int CalculateLinesPerPage(DocumentConfig config)
{
// Estimate: standard letter size is 11 inches, at 10pt font ~66 lines
// This is simplified; in production you'd calculate based on actual page dimensions
var estimatedLineHeight = config.FontSize * 1.2f; // points
var pageHeightInPoints = 11 * 72; // 11 inches * 72 points per inch
return (int)Math.Floor(pageHeightInPoints / estimatedLineHeight);
}
/// <summary>
/// Get PaperSource by logical tray number
/// </summary>
private PaperSource? GetPaperSource(PrinterSettings printerSettings, int trayNumber)
{
// Try to find by RawKind (tray number)
foreach (PaperSource source in printerSettings.PaperSources)
{
// Some printers use RawKind directly as tray number
if (source.RawKind == trayNumber ||
source.RawKind == (trayNumber + 256)) // Some drivers offset by 256
{
return source;
}
}
// Fallback: use by index if available
if (trayNumber > 0 && trayNumber <= printerSettings.PaperSources.Count)
{
return printerSettings.PaperSources[trayNumber - 1];
}
_logger.LogWarning("Could not map tray {Tray} to PaperSource", trayNumber);
return null;
}
/// <summary>
/// List available paper sources for a printer (for debugging/configuration)
/// </summary>
public void ListPaperSources(string printerName)
{
var printDoc = new PrintDocument { PrinterSettings = { PrinterName = printerName } };
_logger.LogInformation("Available paper sources for {Printer}:", printerName);
foreach (PaperSource source in printDoc.PrinterSettings.PaperSources)
{
_logger.LogInformation(" - {Name} (RawKind: {Kind})", source.SourceName, source.RawKind);
}
}
}