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
+82
View File
@@ -0,0 +1,82 @@
# .NET Build Outputs
bin/
obj/
publish/
# Runtime Data Folders (don't commit capture files or queues)
Captures/
Queue/
Archive/
Error/
Output/
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# Visual Studio Code
.vscode/*
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# Visual Studio
.vs/
*.userprefs
# Build results
[Dd]ebug/
[Rr]elease/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# NuGet Packages
*.nupkg
*.snupkg
**/packages/*
!**/packages/build/
*.nuget.props
*.nuget.targets
project.lock.json
project.fragment.lock.json
artifacts/
# Test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# Files built by Visual Studio
*.pidb
*.svclog
*.scc
# ReSharper
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# Windows
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
# macOS
.DS_Store
.AppleDouble
.LSOverride
# Log files
*.log
# Queue state file (contains runtime job queue)
queue-state.json
+392
View File
@@ -0,0 +1,392 @@
{
"version": "2.0.0",
"tasks": [
// ============================================
// BUILD TASKS - Debug
// ============================================
{
"label": "build-service-debug-x86",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/PrintService/PrintService.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary",
"-c",
"Debug",
"-r",
"win-x86"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "build-service-debug-x64",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/PrintService/PrintService.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary",
"-c",
"Debug",
"-r",
"win-x64"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "build-cli-debug-x86",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/PrintServiceCLI/PrintServiceCLI.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary",
"-c",
"Debug",
"-r",
"win-x86"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "build-cli-debug-x64",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/PrintServiceCLI/PrintServiceCLI.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary",
"-c",
"Debug",
"-r",
"win-x64"
],
"problemMatcher": "$msCompile",
"group": "build"
},
// ============================================
// BUILD TASKS - Release
// ============================================
{
"label": "build-service-release-x86",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/PrintService/PrintService.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary",
"-c",
"Release",
"-r",
"win-x86"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "build-service-release-x64",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/PrintService/PrintService.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary",
"-c",
"Release",
"-r",
"win-x64"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "build-cli-release-x86",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/PrintServiceCLI/PrintServiceCLI.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary",
"-c",
"Release",
"-r",
"win-x86"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "build-cli-release-x64",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/PrintServiceCLI/PrintServiceCLI.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary",
"-c",
"Release",
"-r",
"win-x64"
],
"problemMatcher": "$msCompile",
"group": "build"
},
// ============================================
// BUILD ALL TASKS
// ============================================
{
"label": "build-all-x86",
"dependsOn": [
"build-service-release-x86",
"build-cli-release-x86"
],
"dependsOrder": "parallel",
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": []
},
{
"label": "build-all-x64",
"dependsOn": [
"build-service-release-x64",
"build-cli-release-x64"
],
"dependsOrder": "parallel",
"group": "build",
"problemMatcher": []
},
{
"label": "build-all-platforms",
"dependsOn": [
"build-all-x86",
"build-all-x64"
],
"dependsOrder": "sequence",
"group": "build",
"problemMatcher": []
},
// ============================================
// PUBLISH TASKS (Self-Contained)
// ============================================
{
"label": "publish-service-x86-self-contained",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/PrintService/PrintService.csproj",
"-c",
"Release",
"-r",
"win-x86",
"--self-contained",
"true",
"/p:PublishSingleFile=true",
"/p:PublishTrimmed=false",
"-o",
"${workspaceFolder}/publish/x86"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "publish-service-x64-self-contained",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/PrintService/PrintService.csproj",
"-c",
"Release",
"-r",
"win-x64",
"--self-contained",
"true",
"/p:PublishSingleFile=true",
"/p:PublishTrimmed=false",
"-o",
"${workspaceFolder}/publish/x64"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "publish-cli-x86-self-contained",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/PrintServiceCLI/PrintServiceCLI.csproj",
"-c",
"Release",
"-r",
"win-x86",
"--self-contained",
"true",
"/p:PublishSingleFile=true",
"/p:PublishTrimmed=false",
"-o",
"${workspaceFolder}/publish/x86"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "publish-cli-x64-self-contained",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/PrintServiceCLI/PrintServiceCLI.csproj",
"-c",
"Release",
"-r",
"win-x64",
"--self-contained",
"true",
"/p:PublishSingleFile=true",
"/p:PublishTrimmed=false",
"-o",
"${workspaceFolder}/publish/x64"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "publish-all-x86",
"dependsOn": [
"publish-service-x86-self-contained",
"publish-cli-x86-self-contained"
],
"dependsOrder": "parallel",
"group": "build",
"problemMatcher": []
},
{
"label": "publish-all-x64",
"dependsOn": [
"publish-service-x64-self-contained",
"publish-cli-x64-self-contained"
],
"dependsOrder": "parallel",
"group": "build",
"problemMatcher": []
},
// ============================================
// RUN/TEST TASKS
// ============================================
{
"label": "run-service",
"command": "dotnet",
"type": "process",
"args": [
"run",
"--project",
"${workspaceFolder}/PrintService/PrintService.csproj"
],
"problemMatcher": "$msCompile",
"group": "test"
},
{
"label": "run-cli",
"command": "dotnet",
"type": "process",
"args": [
"run",
"--project",
"${workspaceFolder}/PrintServiceCLI/PrintServiceCLI.csproj",
"--",
// Add CLI arguments after --
// Example: "-f", "${workspaceFolder}/Captures/test.txt", "-t", "invoice"
],
"problemMatcher": "$msCompile",
"group": "test"
},
{
"label": "test-pdf-output",
"command": "dotnet",
"type": "process",
"args": [
"run",
"--project",
"${workspaceFolder}/PrintServiceCLI/PrintServiceCLI.csproj",
"--",
"-f",
"${workspaceFolder}/Captures/~DARLENE~20260514@072912.TXT",
"-t",
"test",
"--skip-service-check"
],
"problemMatcher": "$msCompile",
"group": {
"kind": "test",
"isDefault": true
}
},
// ============================================
// WATCH TASKS (Auto-rebuild on changes)
// ============================================
{
"label": "watch-service",
"command": "dotnet",
"type": "process",
"args": [
"watch",
"run",
"--project",
"${workspaceFolder}/PrintService/PrintService.csproj"
],
"problemMatcher": "$msCompile",
"group": "build"
},
// ============================================
// CLEAN TASKS
// ============================================
{
"label": "clean",
"command": "dotnet",
"type": "process",
"args": [
"clean",
"${workspaceFolder}/PrintService.sln"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "clean-publish",
"type": "shell",
"command": "Remove-Item",
"args": [
"-Path",
"${workspaceFolder}/publish",
"-Recurse",
"-Force",
"-ErrorAction",
"SilentlyContinue"
],
"windows": {
"options": {
"shell": {
"executable": "powershell.exe"
}
}
},
"problemMatcher": []
}
]
}
+220
View File
@@ -0,0 +1,220 @@
# Harbor/Clipper Print Service - Implementation Plan
**TL;DR**: Build a persistent Windows service that monitors for new print capture files, immediately moves them to prevent overwrites, queues them for ordered processing, and sends multi-tray print jobs to laser printers with document-type-specific configurations.
**Recommended Approach**: C#/.NET 8+ Windows Service with FileSystemWatcher, print queue, and configurable document templates. C# is completely free (.NET SDK), has superior Windows printer APIs, and excellent service infrastructure.
---
## Steps
### Phase 1: Service Foundation (Parallel work possible after step 1)
1. **Create Windows Service project structure** using .NET 8+ Worker Service template
- Use `BackgroundService` base class for hosting
- Configure as Windows Service with `Microsoft.Extensions.Hosting.WindowsServices`
- Set up dependency injection, logging, and configuration (appsettings.json)
2. **Implement file monitoring system** (*parallel with step 3*)
- `FileSystemWatcher` on Captures folder with `Created`, `Changed`, and `Renamed` events
- Debounce logic to handle rapid file creation (buffer 100-200ms)
- Immediate file move to processing queue folder with GUID-based naming to prevent collisions
- Include original filename, timestamp, and metadata in queue
3. **Design configuration system** (*parallel with step 2*)
- Document type definitions (invoice, order, delivery, etc.)
- Per-document-type settings: printer name, tray sequence, content transformations
- Global settings: queue folder paths, archive settings, retry policies
- JSON schema for easy editing
### Phase 2: Command Interface & Queue Management
4. **Create CLI command handler** (*depends on 1*)
- Parse `-f <filepath> -t <doctype> -o <ordernumber>` arguments
- Check if service is running via named pipe or TCP
- If running: send command to service via IPC
- If not running: log error or auto-start service
- Return exit code immediately
5. **Build print job queue** (*depends on 1*)
- Thread-safe queue with priority/ordering (FIFO by default, configurable)
- Persistent queue state (survive service restart)
- Job metadata: source file, document type, order number, timestamp, retry count
- Status tracking: pending, processing, completed, failed
6. **Implement IPC mechanism** (*depends on 4, 5*)
- Named pipe server listening for commands from CLI
- Deserialize command → create job → enqueue
- Return acknowledgment to caller
### Phase 3: Printer Integration
7. **Implement queue-based printer control** (*depends on 5*)
- Use `System.Drawing.Printing.PrintDocument` with Windows print queue
- Multi-page document with dynamic `PageSettings.PaperSource` for tray selection
- Per-printer tray mapping configuration (map logical tray numbers to physical `PaperSource` indexes)
- Fallback handling when tray empty (configurable: fail job, use default tray, alert)
- Optional: Print to PDF for testing/archiving
8. **Build document processor with Graphics rendering** (*depends on 7*)
- Read queued capture file and parse text content
- Apply document-type-specific transformations:
- Line position adjustments using `Graphics.DrawString()` coordinates
- Text removal/replacement via regex patterns
- Font and formatting configuration per document type
- Future: header/footer injection, graphics overlay, logo placement
- Render each page with `Graphics` API for pixel-perfect control
- Generate multi-page `PrintDocument` with correct tray sequence
- Send to Windows print queue
9. **Add printer routing logic** (*depends on 7, 8*)
- Map document types to printer(s) and tray sequences
- Support multiple printer profiles per document type
- Configuration: `{ "invoice": { "printer": "LaserJet-1", "trays": [3,4,1,2] } }`
### Phase 4: Reliability & Operations
10. **Implement error handling & retry** (*depends on 5, 8*)
- Printer offline detection → requeue with exponential backoff
- File read errors → log and move to error folder
- Max retry limit → move to dead letter queue
- Detailed logging with correlation IDs
11. **Add file lifecycle management** (*depends on 2*)
- Post-processing: delete or move to archive folder (configurable per document type)
- Archive folder structure: organized by date, document type, or both
- Retention policy support (future: auto-cleanup old archives)
12. **Create service installer & deployment** (*depends on all previous*)
- MSI installer or PowerShell install script
- Service registration with `sc.exe` or WiX toolset
- Auto-start configuration
- Uninstall/upgrade support
### Phase 5: Testing & Validation
13. **System testing** (*depends on 12*)
- Test rapid file creation (simulate Harbor/Clipper speed)
- Verify no overwrites or missing files
- Confirm correct print order
- Test printer offline scenarios
- Validate tray sequences for each document type
14. **Integration with Harbor/Clipper** (*depends on 13*)
- Replace current Rust CLI calls with new CLI
- Monitor production for 24-48 hours
- Compare output quality and reliability
- Performance tuning if needed
---
## Relevant Files
**To be created**:
- `PrintService/Program.cs` — Service host and startup configuration
- `PrintService/Worker.cs` — Background service implementing FileSystemWatcher and queue processor
- `PrintService/Models/PrintJob.cs` — Job data model
- `PrintService/Models/DocumentConfig.cs` — Document type configuration schema
- `PrintService/Services/FileMonitorService.cs` — File watching and immediate move logic
- `PrintService/Services/PrintQueueService.cs` — Queue management and processing
- `PrintService/Services/PrinterService.cs` — Queue-based printer control with tray commands
- `PrintService/Services/DocumentProcessor.cs` — Text parsing and Graphics rendering
- `PrintService/Services/IpcService.cs` — Named pipe listener for CLI commands
- `PrintService/appsettings.json` — Configuration file for document types, printers, paths
- `PrintServiceCLI/Program.cs` — Command-line tool for sending commands to service
- `PrintService.sln` — Solution file
**Existing**:
- `Captures/` — Source folder for Harbor/Clipper output files (will be monitored)
---
## Verification
1. **Unit tests**: Queue ordering, configuration parsing, tray sequence generation
2. **Integration test**: Create 50 files in 10 seconds → verify all printed in order with correct trays
3. **Stress test**: 1000+ files → no overwrites, no missing files, proper ordering
4. **Manual test**: Print each document type to each configured printer and verify tray usage
5. **Production validation**: Monitor service for 48 hours, compare output count with Harbor/Clipper expected count
6. **Error scenario testing**: Unplug printer mid-job → verify retry → reconnect → verify job completes
---
## Decisions
**Language**: C#/.NET 8+
- **Rationale**: Free, best Windows service/printer APIs, excellent tooling, easier printer control than Rust
- **Alternative**: Could still use Rust with `windows-rs` crate, but printer control is more complex
**Architecture**: Service with IPC (Named Pipes)
- CLI tool sends commands → service processes asynchronously
- Decouples Harbor/Clipper from processing delays
**File handling**: Immediate move with GUID renaming
- **Critical**: Prevents timestamp collision overwrites
- Original metadata preserved in queue state
**Queue persistence**: JSON file or SQLite
- Survives service restart
- Allows inspection/recovery
**Configuration**: JSON file (appsettings.json)
- Editable without recompilation
- Per-document-type printer/tray/transformation settings
**Printer control**: PrintDocument with Windows print queue
- Per-page tray control via `PageSettings.PaperSource`
- Content rendered using `Graphics.DrawString()` for positioning control
- Jobs appear in Windows print queue (pausable, survives restarts)
- Can print to PDF for testing
**Scope included**:
- Windows service with file monitoring
- Command-line interface for Harbor/Clipper integration
- Multi-tray printer control via Windows print queue
- Configurable document routing
- Archive/delete options
- Error handling and retry logic
- Content transformation with Graphics rendering (line positioning, text removal)
**Scope excluded** (future enhancements):
- GUI management console (use config file + logs for now)
- Advanced document parsing/templating (start with regex transformations)
- Web API or remote monitoring
- Database storage (use file-based queue initially)
- Automatic printer discovery
- Print preview or validation UI
---
## Further Considerations
1. **Printer-specific tray mapping**: Need to identify your laser printer models and test `PaperSource` indexes. Do you know the printer makes/models?
2. **Content transformation complexity**: You mentioned looping line items. For Phase 1, suggest simple regex-based transformations. If parsing becomes complex, consider a template engine (Scriban, Handlebars.NET) in Phase 2.
3. **Testing environment**: Do you have a test laser printer or can we print to PDF initially to verify tray selection and content rendering work correctly?
---
## Benefits of Queue-Based Approach
-**Visibility**: Jobs appear in Windows print queue UI
-**Resilience**: Queue survives service restarts
-**Control**: Can pause/cancel jobs through Windows
-**Testing**: Print to PDF for verification without paper waste
-**Content Control**: `Graphics.DrawString()` gives pixel-perfect positioning for your line adjustments
-**Debugging**: Easier to troubleshoot than raw printer commands
+28
View File
@@ -0,0 +1,28 @@
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}") = "PrintService", "PrintService\PrintService.csproj", "{6FE05D84-E68E-4D43-AD80-7A5D8A59D975}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PrintServiceCLI", "PrintServiceCLI\PrintServiceCLI.csproj", "{799E32EC-0A62-4CA1-92A6-F7E53A2A7F73}"
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
{6FE05D84-E68E-4D43-AD80-7A5D8A59D975}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6FE05D84-E68E-4D43-AD80-7A5D8A59D975}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6FE05D84-E68E-4D43-AD80-7A5D8A59D975}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6FE05D84-E68E-4D43-AD80-7A5D8A59D975}.Release|Any CPU.Build.0 = Release|Any CPU
{799E32EC-0A62-4CA1-92A6-F7E53A2A7F73}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{799E32EC-0A62-4CA1-92A6-F7E53A2A7F73}.Debug|Any CPU.Build.0 = Debug|Any CPU
{799E32EC-0A62-4CA1-92A6-F7E53A2A7F73}.Release|Any CPU.ActiveCfg = Release|Any CPU
{799E32EC-0A62-4CA1-92A6-F7E53A2A7F73}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
+57
View File
@@ -0,0 +1,57 @@
namespace PrintService.Models;
/// <summary>
/// Application settings loaded from appsettings.json
/// </summary>
public class AppSettings
{
/// <summary>
/// Path to monitor for incoming capture files
/// </summary>
public string CapturesPath { get; set; } = "Captures";
/// <summary>
/// Path for queued files (after moving from Captures)
/// </summary>
public string QueuePath { get; set; } = "Queue";
/// <summary>
/// Path for failed jobs
/// </summary>
public string ErrorPath { get; set; } = "Errors";
/// <summary>
/// Default archive path
/// </summary>
public string ArchivePath { get; set; } = "Archive";
/// <summary>
/// Maximum retry attempts before moving to error folder
/// </summary>
public int MaxRetryAttempts { get; set; } = 3;
/// <summary>
/// Debounce delay in milliseconds for file system watcher
/// </summary>
public int DebounceDelayMs { get; set; } = 200;
/// <summary>
/// Named pipe name for IPC communication
/// </summary>
public string PipeName { get; set; } = "PrintServicePipe";
/// <summary>
/// Document type configurations
/// </summary>
public List<DocumentConfig> DocumentTypes { get; set; } = new();
/// <summary>
/// Queue state file path
/// </summary>
public string QueueStateFile { get; set; } = "queue_state.json";
/// <summary>
/// Enable detailed logging
/// </summary>
public bool VerboseLogging { get; set; } = false;
}
+91
View File
@@ -0,0 +1,91 @@
namespace PrintService.Models;
/// <summary>
/// Configuration for document types
/// </summary>
public class DocumentConfig
{
/// <summary>
/// Name/identifier of the document type
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Printer name to send to
/// </summary>
public string PrinterName { get; set; } = string.Empty;
/// <summary>
/// Sequence of tray numbers to use (1-based, e.g., [3, 4, 1, 2])
/// These will be mapped to physical PaperSource indexes
/// </summary>
public int[] TraySequence { get; set; } = Array.Empty<int>();
/// <summary>
/// Font name to use for rendering
/// </summary>
public string FontName { get; set; } = "Courier New";
/// <summary>
/// Font size in points
/// </summary>
public float FontSize { get; set; } = 10f;
/// <summary>
/// Vertical offset adjustment (in pixels)
/// </summary>
public int VerticalOffset { get; set; } = 0;
/// <summary>
/// Horizontal offset adjustment (in pixels)
/// </summary>
public int HorizontalOffset { get; set; } = 0;
/// <summary>
/// Text transformation rules (regex patterns to remove/replace)
/// </summary>
public List<TextTransform> Transformations { get; set; } = new();
/// <summary>
/// Whether to archive files after printing (true) or delete them (false)
/// </summary>
public bool ArchiveAfterPrint { get; set; } = true;
/// <summary>
/// Archive folder path (if ArchiveAfterPrint is true)
/// </summary>
public string? ArchivePath { get; set; }
/// <summary>
/// Output folder for PDF files (when using "Microsoft Print to PDF")
/// If specified, PDFs will be saved to this folder automatically
/// </summary>
public string? OutputPath { get; set; }
/// <summary>
/// Skip post-processing (archive/delete) to leave files in queue for repeated testing
/// Useful for development and testing scenarios
/// </summary>
public bool SkipPostProcessing { get; set; } = false;
}
/// <summary>
/// Text transformation rule
/// </summary>
public class TextTransform
{
/// <summary>
/// Regex pattern to match
/// </summary>
public string Pattern { get; set; } = string.Empty;
/// <summary>
/// Replacement text (empty string to remove)
/// </summary>
public string Replacement { get; set; } = string.Empty;
/// <summary>
/// Description of what this transformation does
/// </summary>
public string Description { get; set; } = string.Empty;
}
+74
View File
@@ -0,0 +1,74 @@
namespace PrintService.Models;
/// <summary>
/// Represents a print job in the queue
/// </summary>
public class PrintJob
{
/// <summary>
/// Unique identifier for the job
/// </summary>
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// Path to the original capture file
/// </summary>
public string SourceFilePath { get; set; } = string.Empty;
/// <summary>
/// Path to the queued file (after being moved with GUID name)
/// </summary>
public string QueuedFilePath { get; set; } = string.Empty;
/// <summary>
/// Document type (invoice, order, delivery, etc.)
/// </summary>
public string DocumentType { get; set; } = string.Empty;
/// <summary>
/// Optional order number
/// </summary>
public string? OrderNumber { get; set; }
/// <summary>
/// When the job was created
/// </summary>
public DateTime CreatedAt { get; set; } = DateTime.Now;
/// <summary>
/// When the job was last updated
/// </summary>
public DateTime UpdatedAt { get; set; } = DateTime.Now;
/// <summary>
/// Current status of the job
/// </summary>
public PrintJobStatus Status { get; set; } = PrintJobStatus.Pending;
/// <summary>
/// Number of times this job has been attempted
/// </summary>
public int RetryCount { get; set; } = 0;
/// <summary>
/// Last error message if failed
/// </summary>
public string? LastError { get; set; }
/// <summary>
/// Original filename before moving to queue
/// </summary>
public string OriginalFilename { get; set; } = string.Empty;
}
/// <summary>
/// Status of a print job
/// </summary>
public enum PrintJobStatus
{
Pending,
Processing,
Completed,
Failed,
RetryScheduled
}
+21
View File
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-PrintService-3e06b535-3a68-4806-9c5b-3a32bde82bf9</UserSecretsId>
<OutputType>Exe</OutputType>
</PropertyGroup>
<!-- Use x86 only for Release builds (for 32-bit target machines) -->
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<PlatformTarget>x86</PlatformTarget>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="7.0.0" />
<PackageReference Include="System.Drawing.Common" Version="7.0.0" />
</ItemGroup>
</Project>
+34
View File
@@ -0,0 +1,34 @@
using PrintService;
using PrintService.Models;
using PrintService.Services;
using Microsoft.Extensions.Options;
IHost host = Host.CreateDefaultBuilder(args)
.UseWindowsService(options =>
{
options.ServiceName = "LAAPC Print Service";
})
.ConfigureServices((hostContext, services) =>
{
// Bind configuration
services.Configure<AppSettings>(hostContext.Configuration.GetSection("AppSettings"));
// Register services
services.AddSingleton<PrintQueueService>();
services.AddSingleton<FileMonitorService>();
services.AddSingleton<PrinterService>();
services.AddSingleton<DocumentProcessor>();
services.AddSingleton<IpcService>();
// Register the main worker
services.AddHostedService<Worker>();
})
.Build();
// Ensure directories exist
var config = host.Services.GetRequiredService<IOptions<AppSettings>>().Value;
Directory.CreateDirectory(config.QueuePath);
Directory.CreateDirectory(config.ErrorPath);
Directory.CreateDirectory(config.ArchivePath);
host.Run();
@@ -0,0 +1,11 @@
{
"profiles": {
"PrintService": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}
+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);
}
}
}
+157
View File
@@ -0,0 +1,157 @@
using PrintService.Models;
using PrintService.Services;
namespace PrintService;
/// <summary>
/// Main worker service that coordinates all components
/// </summary>
public class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
private readonly PrintQueueService _queueService;
private readonly FileMonitorService _fileMonitorService;
private readonly PrinterService _printerService;
private readonly DocumentProcessor _documentProcessor;
private readonly IpcService _ipcService;
public Worker(
ILogger<Worker> logger,
PrintQueueService queueService,
FileMonitorService fileMonitorService,
PrinterService printerService,
DocumentProcessor documentProcessor,
IpcService ipcService)
{
_logger = logger;
_queueService = queueService;
_fileMonitorService = fileMonitorService;
_printerService = printerService;
_documentProcessor = documentProcessor;
_ipcService = ipcService;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("LAAPC Print Service starting at: {time}", DateTimeOffset.Now);
try
{
// Start IPC server for CLI communication
_ipcService.Start();
// Start file monitoring (optional - mainly using IPC for job creation)
// _fileMonitorService.Start();
_logger.LogInformation("Print service started successfully");
_logger.LogInformation("Queue has {Count} pending jobs", _queueService.Count);
// Main processing loop
while (!stoppingToken.IsCancellationRequested)
{
try
{
if (_queueService.TryDequeue(out var job) && job != null)
{
await ProcessJob(job, stoppingToken);
}
else
{
// No jobs in queue, wait a bit
await Task.Delay(500, stoppingToken);
}
}
catch (OperationCanceledException)
{
// Expected during shutdown, exit gracefully
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in processing loop");
try
{
await Task.Delay(1000, stoppingToken);
}
catch (OperationCanceledException)
{
// Shutdown requested during error delay
break;
}
}
}
}
catch (OperationCanceledException)
{
// Expected during shutdown
_logger.LogInformation("Service shutdown requested");
}
catch (Exception ex)
{
_logger.LogCritical(ex, "Fatal error in worker service");
throw;
}
finally
{
_logger.LogInformation("Print service stopping");
_ipcService.Stop();
_fileMonitorService.Stop();
}
}
/// <summary>
/// Process a single print job
/// </summary>
private async Task ProcessJob(PrintJob job, CancellationToken cancellationToken)
{
_logger.LogInformation("Processing job {JobId} (Type: {DocType}, Order: {OrderNum})",
job.Id, job.DocumentType, job.OrderNumber ?? "N/A");
job.Status = PrintJobStatus.Processing;
job.UpdatedAt = DateTime.Now;
try
{
// Get document configuration
var config = _documentProcessor.GetDocumentConfig(job.DocumentType);
if (config == null)
{
throw new InvalidOperationException($"No configuration found for document type: {job.DocumentType}");
}
// Read file content
if (!File.Exists(job.QueuedFilePath))
{
throw new FileNotFoundException($"Queued file not found: {job.QueuedFilePath}");
}
var content = _documentProcessor.ReadFile(job.QueuedFilePath);
// Process/transform content
var processedContent = _documentProcessor.ProcessDocument(content, config);
// Print to Windows queue
await Task.Run(() => _printerService.Print(processedContent, config, job.OriginalFilename), cancellationToken);
// Post-process (archive or delete)
_documentProcessor.PostProcess(job, config);
// Mark as completed
job.Status = PrintJobStatus.Completed;
job.UpdatedAt = DateTime.Now;
_logger.LogInformation("Job {JobId} completed successfully", job.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process job {JobId}", job.Id);
job.LastError = ex.Message;
job.UpdatedAt = DateTime.Now;
// Requeue for retry
_queueService.Requeue(job);
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}
+76
View File
@@ -0,0 +1,76 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AppSettings": {
"CapturesPath": "C:\\Users\\Work\\Desktop\\LAAPC\\Captures",
"QueuePath": "C:\\Users\\Work\\Desktop\\LAAPC\\Queue",
"ErrorPath": "C:\\Users\\Work\\Desktop\\LAAPC\\Errors",
"ArchivePath": "C:\\Users\\Work\\Desktop\\LAAPC\\Archive",
"MaxRetryAttempts": 3,
"DebounceDelayMs": 200,
"PipeName": "PrintServicePipe",
"QueueStateFile": "C:\\Users\\Work\\Desktop\\LAAPC\\queue_state.json",
"VerboseLogging": true,
"DocumentTypes": [
{
"Name": "invoice",
"PrinterName": "Microsoft Print to PDF",
"TraySequence": [ 3, 4, 1, 2 ],
"FontName": "Courier New",
"FontSize": 10.0,
"VerticalOffset": 0,
"HorizontalOffset": 0,
"ArchiveAfterPrint": true,
"ArchivePath": "C:\\Users\\Work\\Desktop\\LAAPC\\Archive\\Invoices",
"Transformations": [
{
"Pattern": "W1DUPLICATE INVW0",
"Replacement": "",
"Description": "Remove duplicate invoice marker"
}
]
},
{
"Name": "order",
"PrinterName": "Brother HL-2270DW series Printer",
"TraySequence": [ 4, 2, 1 ],
"FontName": "Courier New",
"FontSize": 10.0,
"VerticalOffset": 0,
"HorizontalOffset": 0,
"ArchiveAfterPrint": true,
"ArchivePath": "C:\\Users\\Work\\Desktop\\LAAPC\\Archive\\Orders",
"Transformations": []
},
{
"Name": "delivery",
"PrinterName": "Brother HL-2270DW series Printer",
"TraySequence": [ 3, 1 ],
"FontName": "Courier New",
"FontSize": 10.0,
"VerticalOffset": 0,
"HorizontalOffset": 0,
"ArchiveAfterPrint": true,
"ArchivePath": "C:\\Users\\Work\\Desktop\\LAAPC\\Archive\\Delivery",
"Transformations": []
},
{
"Name": "test",
"PrinterName": "Microsoft Print to PDF",
"TraySequence": [ 1 ],
"FontName": "Courier New",
"FontSize": 10.0,
"VerticalOffset": 0,
"HorizontalOffset": 0,
"ArchiveAfterPrint": false,
"OutputPath": "C:\\Users\\Work\\Desktop\\LAAPC\\Output",
"SkipPostProcessing": true,
"Transformations": []
}
]
}
}
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<!-- Use x86 only for Release builds (for 32-bit target machines) -->
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<PlatformTarget>x86</PlatformTarget>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.IO.Pipes" Version="4.3.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="7.0.0" />
</ItemGroup>
</Project>
+232
View File
@@ -0,0 +1,232 @@
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
namespace PrintServiceCLI;
class Program
{
private const string PipeName = "PrintServicePipe";
private const int TimeoutMs = 5000;
static async Task<int> Main(string[] args)
{
try
{
// Handle special --install-service flag (called by elevated process)
if (args.Contains("--install-service"))
{
return ServiceManager.EnsureServiceRunning(silent: false) ? 0 : 1;
}
// Check for skip-service-check flag
bool skipServiceCheck = args.Contains("--skip-service-check");
// Parse arguments
var command = ParseArguments(args, out bool showHelp);
if (showHelp || command == null)
{
ShowUsage();
return command == null ? 1 : 0;
}
// Ensure service is running (unless testing)
if (!skipServiceCheck)
{
if (!ServiceManager.EnsureServiceRunning())
{
Console.Error.WriteLine("ERROR: Service is not available. Cannot process print request.");
return 1;
}
}
var result = await SendCommandToService(command);
if (result.Success)
{
Console.WriteLine($"SUCCESS: {result.Message}");
return 0;
}
else
{
Console.Error.WriteLine($"ERROR: {result.Message}");
return 1;
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"FATAL ERROR: {ex.Message}");
return 1;
}
}
/// <summary>
/// Parse command line arguments
/// </summary>
static PrintCommand? ParseArguments(string[] args, out bool showHelp)
{
showHelp = false;
string? filePath = null;
string? documentType = null;
string? orderNumber = null;
for (int i = 0; i < args.Length; i++)
{
switch (args[i].ToLower())
{
case "-f":
case "--file":
if (i + 1 < args.Length)
{
filePath = args[++i];
}
break;
case "-t":
case "--type":
if (i + 1 < args.Length)
{
documentType = args[++i];
}
break;
case "-o":
case "--order":
if (i + 1 < args.Length)
{
orderNumber = args[++i];
}
break;
case "-h":
case "--help":
case "?":
showHelp = true;
return null;
case "--skip-service-check":
case "--install-service":
// These flags are handled in Main, skip here
break;
}
}
// Validate required arguments
if (string.IsNullOrEmpty(filePath))
{
Console.Error.WriteLine("Error: File path is required (-f)");
return null;
}
if (string.IsNullOrEmpty(documentType))
{
Console.Error.WriteLine("Error: Document type is required (-t)");
return null;
}
if (!File.Exists(filePath))
{
Console.Error.WriteLine($"Error: File not found: {filePath}");
return null;
}
return new PrintCommand
{
FilePath = Path.GetFullPath(filePath),
DocumentType = documentType,
OrderNumber = orderNumber
};
}
/// <summary>
/// Send command to service via named pipe
/// </summary>
static async Task<PrintResponse> SendCommandToService(PrintCommand command)
{
using var pipeClient = new NamedPipeClientStream(
".",
PipeName,
PipeDirection.InOut,
PipeOptions.Asynchronous);
try
{
await pipeClient.ConnectAsync(TimeoutMs);
}
catch (TimeoutException)
{
return new PrintResponse
{
Success = false,
Message = "Service not responding. Is the LAAPC Print Service running?"
};
}
catch (IOException ex)
{
return new PrintResponse
{
Success = false,
Message = $"Cannot connect to service: {ex.Message}"
};
}
// Send command
var json = JsonSerializer.Serialize(command);
var bytes = Encoding.UTF8.GetBytes(json);
await pipeClient.WriteAsync(bytes, 0, bytes.Length);
await pipeClient.FlushAsync();
// Read response
var buffer = new byte[4096];
var bytesRead = await pipeClient.ReadAsync(buffer, 0, buffer.Length);
var responseJson = Encoding.UTF8.GetString(buffer, 0, bytesRead);
var response = JsonSerializer.Deserialize<PrintResponse>(responseJson);
return response ?? new PrintResponse { Success = false, Message = "Invalid response from service" };
}
/// <summary>
/// Show usage information
/// </summary>
static void ShowUsage()
{
Console.WriteLine("LAAPC Print Service CLI");
Console.WriteLine();
Console.WriteLine("Usage: PrintServiceCLI -f <filepath> -t <doctype> [-o <ordernumber>]");
Console.WriteLine();
Console.WriteLine("Options:");
Console.WriteLine(" -f, --file <path> Path to the capture file to print (required)");
Console.WriteLine(" -t, --type <type> Document type (required)");
Console.WriteLine(" Examples: invoice, order, delivery");
Console.WriteLine(" -o, --order <number> Optional order number");
Console.WriteLine(" -h, --help Show this help message");
Console.WriteLine(" --skip-service-check Skip service availability check (for testing)");
Console.WriteLine();
Console.WriteLine("Examples:");
Console.WriteLine(" PrintServiceCLI -f \"C:\\Captures\\file.txt\" -t invoice");
Console.WriteLine(" PrintServiceCLI -f \"C:\\Captures\\file.txt\" -t order -o 12345");
Console.WriteLine();
Console.WriteLine("For Development/Testing:");
Console.WriteLine(" PrintServiceCLI -f \"file.txt\" -t test --skip-service-check");
}
}
/// <summary>
/// Command to send to service
/// </summary>
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
/// </summary>
class PrintResponse
{
public bool Success { get; set; }
public string Message { get; set; } = string.Empty;
}
+290
View File
@@ -0,0 +1,290 @@
using System.Diagnostics;
using System.ServiceProcess;
using System.Security.Principal;
namespace PrintServiceCLI;
/// <summary>
/// Manages Windows Service installation and verification
/// </summary>
public static class ServiceManager
{
private const string ServiceName = "LAAPC Print Service";
private const string ServiceInstallPath = @"C:\ProgramData\LAAPC";
private const string ServiceExeName = "PrintService.exe";
private static bool? _serviceAvailable = null; // Cache result
/// <summary>
/// Check if service is installed and running, with auto-install option
/// </summary>
public static bool EnsureServiceRunning(bool silent = false)
{
// Return cached result if already checked
if (_serviceAvailable.HasValue)
return _serviceAvailable.Value;
try
{
// Check if service exists and is running
using var service = new ServiceController(ServiceName);
service.Refresh();
if (service.Status == ServiceControllerStatus.Running)
{
_serviceAvailable = true;
return true;
}
// Service exists but not running - try to start it
if (service.Status == ServiceControllerStatus.Stopped)
{
if (!silent)
Console.WriteLine("Service is stopped. Starting...");
service.Start();
service.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(10));
_serviceAvailable = true;
return true;
}
_serviceAvailable = false;
return false;
}
catch (InvalidOperationException)
{
// Service doesn't exist - offer to install
if (silent)
{
_serviceAvailable = false;
return false;
}
return PromptAndInstall();
}
catch (Exception ex)
{
if (!silent)
Console.Error.WriteLine($"Error checking service: {ex.Message}");
_serviceAvailable = false;
return false;
}
}
/// <summary>
/// Prompt user and install service if they agree
/// </summary>
private static bool PromptAndInstall()
{
Console.WriteLine();
Console.WriteLine("====================================================");
Console.WriteLine("LAAPC Print Service is not installed on this PC.");
Console.WriteLine("====================================================");
Console.WriteLine();
Console.WriteLine("The service must be installed locally to process print jobs.");
Console.WriteLine("This is a one-time setup that requires administrator access.");
Console.WriteLine();
Console.Write("Install the service now? [Y/N]: ");
var response = Console.ReadLine()?.Trim().ToUpper();
if (response != "Y" && response != "YES")
{
Console.WriteLine("Installation cancelled. Service is required for printing.");
_serviceAvailable = false;
return false;
}
return InstallService();
}
/// <summary>
/// Install and start the service with UAC elevation
/// </summary>
private static bool InstallService()
{
try
{
Console.WriteLine();
Console.WriteLine("Installing service...");
// Check if running as admin
if (!IsAdministrator())
{
Console.WriteLine("Requesting administrator privileges...");
return InstallWithElevation();
}
// Copy service files to local machine
var cliPath = AppContext.BaseDirectory;
var serviceSourcePath = Path.Combine(Path.GetDirectoryName(cliPath)!, ServiceExeName);
if (!File.Exists(serviceSourcePath))
{
// Try relative path
serviceSourcePath = Path.Combine(cliPath, "..", "PrintService", ServiceExeName);
serviceSourcePath = Path.GetFullPath(serviceSourcePath);
}
if (!File.Exists(serviceSourcePath))
{
Console.Error.WriteLine($"ERROR: Could not find {ServiceExeName}");
Console.Error.WriteLine($"Expected location: {serviceSourcePath}");
_serviceAvailable = false;
return false;
}
// Create installation directory
Directory.CreateDirectory(ServiceInstallPath);
// Copy service executable and dependencies
var serviceDestPath = Path.Combine(ServiceInstallPath, ServiceExeName);
CopyServiceFiles(Path.GetDirectoryName(serviceSourcePath)!, ServiceInstallPath);
Console.WriteLine($"Files copied to: {ServiceInstallPath}");
// Install Windows Service using sc.exe
var scInstall = Process.Start(new ProcessStartInfo
{
FileName = "sc.exe",
Arguments = $"create \"{ServiceName}\" binPath=\"{serviceDestPath}\" start=auto",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
});
scInstall?.WaitForExit();
if (scInstall?.ExitCode != 0)
{
Console.Error.WriteLine("ERROR: Failed to install service");
_serviceAvailable = false;
return false;
}
Console.WriteLine("Service installed successfully.");
// Start the service
Console.WriteLine("Starting service...");
var scStart = Process.Start(new ProcessStartInfo
{
FileName = "sc.exe",
Arguments = $"start \"{ServiceName}\"",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
});
scStart?.WaitForExit();
if (scStart?.ExitCode != 0)
{
Console.Error.WriteLine("WARNING: Service installed but failed to start");
Console.Error.WriteLine("Try running: sc start \"LAAPC Print Service\"");
_serviceAvailable = false;
return false;
}
// Wait for service to be ready
Thread.Sleep(2000);
Console.WriteLine("Service started successfully!");
Console.WriteLine();
_serviceAvailable = true;
return true;
}
catch (Exception ex)
{
Console.Error.WriteLine($"ERROR: Installation failed: {ex.Message}");
_serviceAvailable = false;
return false;
}
}
/// <summary>
/// Restart process with admin elevation
/// </summary>
private static bool InstallWithElevation()
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = Process.GetCurrentProcess().MainModule?.FileName ?? "PrintServiceCLI.exe",
Arguments = "--install-service",
UseShellExecute = true,
Verb = "runas" // Trigger UAC prompt
};
var process = Process.Start(startInfo);
process?.WaitForExit();
if (process?.ExitCode == 0)
{
Console.WriteLine("Service installed successfully.");
_serviceAvailable = true;
return true;
}
Console.Error.WriteLine("Installation was cancelled or failed.");
_serviceAvailable = false;
return false;
}
catch (Exception ex)
{
Console.Error.WriteLine($"ERROR: Could not elevate privileges: {ex.Message}");
_serviceAvailable = false;
return false;
}
}
/// <summary>
/// Copy service files from source to destination
/// </summary>
private static void CopyServiceFiles(string sourceDir, string destDir)
{
// Copy main executable
var sourceExe = Path.Combine(sourceDir, ServiceExeName);
var destExe = Path.Combine(destDir, ServiceExeName);
File.Copy(sourceExe, destExe, overwrite: true);
// Copy all DLLs and config files
foreach (var file in Directory.GetFiles(sourceDir))
{
var fileName = Path.GetFileName(file);
var ext = Path.GetExtension(file).ToLower();
if (ext == ".dll" || ext == ".json" || ext == ".config")
{
var destFile = Path.Combine(destDir, fileName);
File.Copy(file, destFile, overwrite: true);
}
}
// Create necessary folders
var capturesPath = Path.Combine(destDir, "Captures");
var queuePath = Path.Combine(destDir, "Queue");
var archivePath = Path.Combine(destDir, "Archive");
var errorPath = Path.Combine(destDir, "Error");
var outputPath = Path.Combine(destDir, "Output");
Directory.CreateDirectory(capturesPath);
Directory.CreateDirectory(queuePath);
Directory.CreateDirectory(archivePath);
Directory.CreateDirectory(errorPath);
Directory.CreateDirectory(outputPath);
}
/// <summary>
/// Check if running with administrator privileges
/// </summary>
private static bool IsAdministrator()
{
using var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
return principal.IsInRole(WindowsBuiltInRole.Administrator);
}
}
+283
View File
@@ -0,0 +1,283 @@
# LAAPC Print Service
A Windows service for managing print jobs from Harbor/Clipper legacy applications to modern laser printers with multi-tray support.
## Overview
This service solves the problem of migrating from dot-matrix printers with carbon copy paper to laser printers with colored paper trays. It:
-**Prevents file overwrites** by immediately moving capture files to a queue with GUID-based names
-**Maintains print order** with a persistent queue system
-**Controls printer trays** via Windows print queue API for "carbon copy" simulation
-**Transforms content** with configurable rules per document type
-**Archives prints** for record-keeping
## Architecture
- **PrintService**: Windows service that runs continuously, monitors for print jobs, and processes them
- **PrintServiceCLI**: Command-line tool for Harbor/Clipper to submit print jobs via IPC (named pipes)
## Quick Start
### 1. Configure
Edit `PrintService/appsettings.json`:
```json
{
"AppSettings": {
"CapturesPath": "C:\\Users\\Work\\Desktop\\LAAPC\\Captures",
"DocumentTypes": [
{
"Name": "invoice",
"PrinterName": "Your_Actual_Printer_Name",
"TraySequence": [ 3, 4, 1, 2 ]
}
]
}
}
```
**Important**: Replace `Your_Actual_Printer_Name` with actual Windows printer name(s).
### 2. Build
```powershell
# Build for 32-bit (supports both 32-bit and 64-bit Windows)
dotnet build -c Release -r win-x86
# Or publish self-contained (includes .NET runtime)
dotnet publish -c Release -r win-x86 --self-contained true -o publish/x86
```
### 3. Install Service
```powershell
# Run as Administrator
sc.exe create "LAAPC Print Service" binPath="C:\Path\To\PrintService.exe" start=auto
sc.exe start "LAAPC Print Service"
```
### 4. Test CLI
```powershell
PrintServiceCLI.exe -f "C:\Captures\test.txt" -t invoice -o 12345
```
## Usage
### From Harbor/Clipper
Replace your current Rust CLI calls with:
```
PrintServiceCLI.exe -f <filepath> -t <doctype> [-o <ordernumber>]
```
**Examples**:
```
PrintServiceCLI.exe -f "C:\Captures\inv001.txt" -t invoice
PrintServiceCLI.exe -f "C:\Captures\ord002.txt" -t order -o 600005
PrintServiceCLI.exe -f "C:\Captures\del003.txt" -t delivery
```
### CLI Options
- `-f, --file <path>`: Path to capture file (required)
- `-t, --type <type>`: Document type: invoice, order, delivery, etc. (required)
- `-o, --order <num>`: Optional order number
- `-h, --help`: Show help
## Configuration
### Document Types
Each document type in `appsettings.json` specifies:
```json
{
"Name": "invoice", // Document type identifier
"PrinterName": "HP LaserJet 500", // Windows printer name
"TraySequence": [ 3, 4, 1, 2 ], // Tray order (simulates carbon copy)
"FontName": "Courier New", // Font for rendering
"FontSize": 10.0, // Font size in points
"VerticalOffset": 0, // Adjust vertical positioning (pixels)
"HorizontalOffset": 0, // Adjust horizontal positioning (pixels)
"ArchiveAfterPrint": true, // Archive or delete after printing
"ArchivePath": "C:\\Archive\\Invoices", // Where to archive
"Transformations": [ // Text transformation rules
{
"Pattern": "W1DUPLICATE INVW0", // Regex pattern to find
"Replacement": "", // Replace with (empty = remove)
"Description": "Remove duplicate marker"
}
]
}
```
### Tray Mapping
The `TraySequence` specifies which physical printer trays to use for each page/copy:
- **Example**: `[3, 4, 1, 2]` prints:
- Page 1 → Tray 3 (e.g., Pink paper)
- Page 2 → Tray 4 (e.g., Orange paper)
- Page 3 → Tray 1 (e.g., Blue paper)
- Page 4 → Tray 2 (e.g., Green paper)
**Note**: Tray numbers may need adjustment per printer model. Use the included tray discovery tool (see Troubleshooting).
## Project Structure
```
PrintService/
├── Models/
│ ├── PrintJob.cs - Print job data model
│ ├── DocumentConfig.cs - Document type configuration
│ └── AppSettings.cs - Application settings
├── Services/
│ ├── PrintQueueService.cs - Job queue management
│ ├── FileMonitorService.cs - File system monitoring
│ ├── PrinterService.cs - Windows printer integration
│ ├── DocumentProcessor.cs - Content transformation
│ └── IpcService.cs - Named pipe IPC server
├── Worker.cs - Main service coordinator
├── Program.cs - Service host configuration
└── appsettings.json - Configuration file
PrintServiceCLI/
└── Program.cs - Command-line interface
```
## Troubleshooting
### Service won't start
1. Check Windows Event Viewer → Application logs
2. Verify paths in `appsettings.json` exist
3. Run with elevated privileges
### Printer not found
List installed printers:
```powershell
Get-Printer | Select-Object Name
```
Update `PrinterName` in config to match exactly.
### Wrong trays selected
Tray mapping varies by printer model. To discover available trays, temporarily add logging to `PrinterService.ListPaperSources()` and check service logs.
### Files being overwritten
Ensure Harbor/Clipper is calling the CLI (not writing directly to Captures folder). The service immediately moves files to prevent timestamp collisions.
### Print order issues
Check `queue_state.json` - jobs are processed FIFO. If order is wrong, check file timestamps.
## Monitoring
### Service Status
```powershell
Get-Service "LAAPC Print Service"
sc.exe query "LAAPC Print Service"
```
### Logs
Check Windows Event Viewer or configure file logging in `appsettings.json`.
### Queue State
Inspect `queue_state.json` (location specified in `appsettings.json`) to see pending jobs.
### Folders
- `Queue/` - Files being processed (GUID-named)
- `Archive/` - Completed jobs (if archiving enabled)
- `Errors/` - Failed jobs after max retries
## Uninstall
```powershell
# Run as Administrator
sc.exe stop "LAAPC Print Service"
sc.exe delete "LAAPC Print Service"
```
## Development
### Build for debugging
```powershell
dotnet build -c Debug
```
### Run service locally (not as Windows Service)
```powershell
dotnet run --project PrintService/PrintService.csproj
```
### Watch mode (auto-rebuild on changes)
```powershell
dotnet watch run --project PrintService/PrintService.csproj
```
### VS Code Tasks
Use Command Palette (`Ctrl+Shift+P`) → "Tasks: Run Task":
- `build-all-x86` - Build release for 32-bit
- `publish-all-x86` - Create deployment package
- `run-service` - Run service locally for testing
## Technical Details
### IPC Protocol
The CLI communicates with the service via Windows Named Pipes (`\\.\pipe\PrintServicePipe`):
**Request**:
```json
{
"FilePath": "C:\\Captures\\file.txt",
"DocumentType": "invoice",
"OrderNumber": "12345"
}
```
**Response**:
```json
{
"Success": true,
"Message": "Job {guid} queued successfully"
}
```
### Print Queue Integration
Uses `System.Drawing.Printing.PrintDocument` with per-page `PageSettings.PaperSource` control:
- Content rendered with `Graphics.DrawString()` for pixel-perfect positioning
- Jobs go through Windows print queue (visible in Windows printer UI)
- Survives service restarts via persistent queue state
### File Safety
1. CLI sends command with file path
2. Service immediately moves file to Queue folder with GUID name
3. Original filename preserved in job metadata
4. Prevents timestamp collision overwrites
## License
[Your License Here]
## Support
For issues or questions, contact [Your Contact Info]
+1
View File
@@ -0,0 +1 @@
[]