4 Commits

Author SHA1 Message Date
Jason f5976a04c9 Merge branch 'agents/last-commit-checkpoint' 2026-05-29 10:49:43 -05:00
Jason 5623a6efbc Added tray icon support 2026-05-29 10:46:43 -05:00
Jason a08da0217d feat: Add Print Service Tray application with configuration and queue management
- Implemented PrintQueueService with job counting and snapshot retrieval.
- Created Form1 as the main entry point for the tray application.
- Developed ConfigurationForm for managing printer configurations and document types.
- Added models for printer configuration, queue status requests, and responses.
- Established ConfigurationService for loading and saving printer configurations.
- Introduced ServiceClient for IPC communication with the print service.
- Built TrayApplicationContext for managing the system tray icon and status updates.
- Added TODO.md for tracking remaining work and future enhancements.
2026-05-29 10:43:48 -05:00
Jason cce9248089 Initial commit: LAAPC Print Service - Windows service with multi-tray printing, IPC queue management, PDF testing support, and self-installing CLI 2026-05-14 16:59:59 -05:00
35 changed files with 4511 additions and 56 deletions
+85 -54
View File
@@ -1,54 +1,85 @@
# ---> C # .NET Build Outputs
# Prerequisites bin/
*.d obj/
publish/
# Object files
*.o # Runtime Data Folders (don't commit capture files or queues)
*.ko Captures/
*.obj Queue/
*.elf Archive/
Error/
# Linker output Output/
*.ilk
*.map # Rust Project (reference implementation)
*.exp RUST/
# Precompiled Headers # User-specific files
*.gch *.suo
*.pch *.user
*.userosscache
# Libraries *.sln.docstates
*.lib
*.a # Visual Studio Code
*.la .vscode/*
*.lo !.vscode/tasks.json
!.vscode/launch.json
# Shared objects (inc. Windows DLLs) !.vscode/extensions.json
*.dll
*.so # Visual Studio
*.so.* .vs/
*.dylib *.userprefs
# Executables # Build results
*.exe [Dd]ebug/
*.out [Rr]elease/
*.app x64/
*.i*86 x86/
*.x86_64 [Aa][Rr][Mm]/
*.hex [Aa][Rr][Mm]64/
bld/
# Debug files [Bb]in/
*.dSYM/ [Oo]bj/
*.su [Ll]og/
*.idb [Ll]ogs/
*.pdb
# NuGet Packages
# Kernel Module Compile Results *.nupkg
*.mod* *.snupkg
*.cmd **/packages/*
.tmp_versions/ !**/packages/build/
modules.order *.nuget.props
Module.symvers *.nuget.targets
Mkfile.old project.lock.json
dkms.conf 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
+502
View File
@@ -0,0 +1,502 @@
{
"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",
"build-tray-release-x86"
],
"dependsOrder": "parallel",
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": []
},
{
"label": "build-all-x64",
"dependsOn": [
"build-service-release-x64",
"build-cli-release-x64",
"build-tray-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",
"publish-tray-x86-self-contained"
],
"dependsOrder": "parallel",
"group": "build",
"problemMatcher": []
},
{
"label": "publish-all-x64",
"dependsOn": [
"publish-service-x64-self-contained",
"publish-cli-x64-self-contained",
"publish-tray-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"
},
// ============================================
// TRAY APP TASKS
// ============================================
{
"label": "build-tray-debug",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/PrintServiceTray/PrintServiceTray.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary",
"-c",
"Debug"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "build-tray-release-x86",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/PrintServiceTray/PrintServiceTray.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary",
"-c",
"Release",
"-r",
"win-x86"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "build-tray-release-x64",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/PrintServiceTray/PrintServiceTray.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary",
"-c",
"Release",
"-r",
"win-x64"
],
"problemMatcher": "$msCompile",
"group": "build"
},
{
"label": "run-tray",
"command": "dotnet",
"type": "process",
"args": [
"run",
"--project",
"${workspaceFolder}/PrintServiceTray/PrintServiceTray.csproj"
],
"problemMatcher": "$msCompile",
"group": "test"
},
{
"label": "publish-tray-x86-self-contained",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/PrintServiceTray/PrintServiceTray.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-tray-x64-self-contained",
"command": "dotnet",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/PrintServiceTray/PrintServiceTray.csproj",
"-c",
"Release",
"-r",
"win-x64",
"--self-contained",
"true",
"/p:PublishSingleFile=true",
"/p:PublishTrimmed=false",
"-o",
"${workspaceFolder}/publish/x64"
],
"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
+34
View File
@@ -0,0 +1,34 @@
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
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PrintServiceTray", "PrintServiceTray\PrintServiceTray.csproj", "{42CF28FF-90E4-4912-B427-EA5F675F641E}"
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
{42CF28FF-90E4-4912-B427-EA5F675F641E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{42CF28FF-90E4-4912-B427-EA5F675F641E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{42CF28FF-90E4-4912-B427-EA5F675F641E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{42CF28FF-90E4-4912-B427-EA5F675F641E}.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();
}
}
+340
View File
@@ -0,0 +1,340 @@
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);
}
// Try to determine command type
var commandType = DetermineCommandType(message);
if (commandType == "GetQueueStatus")
{
var status = GetQueueStatus();
await SendQueueStatusResponse(pipeServer, status);
return;
}
// Handle print command
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>
/// Determine the type of command from JSON message
/// </summary>
private string? DetermineCommandType(string message)
{
try
{
using var doc = JsonDocument.Parse(message);
if (doc.RootElement.TryGetProperty("Command", out var cmdProp))
{
return cmdProp.GetString();
}
}
catch { }
return null;
}
/// <summary>
/// Get current queue status
/// </summary>
private QueueStatusResponse GetQueueStatus()
{
try
{
var jobs = _queueService.GetAllJobs();
var jobStatuses = jobs.Select(j => new JobStatus
{
Id = j.Id.ToString(),
FileName = j.OriginalFilename,
DocumentType = j.DocumentType,
Status = j.Status.ToString(),
PrinterName = null, // Will be set when processing starts
CurrentPage = null,
TotalPages = null
}).ToList();
return new QueueStatusResponse
{
Success = true,
Jobs = jobStatuses
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get queue status");
return new QueueStatusResponse
{
Success = false,
Message = $"Error: {ex.Message}"
};
}
}
/// <summary>
/// Send queue status response
/// </summary>
private async Task SendQueueStatusResponse(NamedPipeServerStream pipeServer, QueueStatusResponse response)
{
var json = JsonSerializer.Serialize(response);
var bytes = Encoding.UTF8.GetBytes(json);
await pipeServer.WriteAsync(bytes, 0, bytes.Length);
await pipeServer.FlushAsync();
}
/// <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;
}
/// <summary>
/// Queue status response for tray app
/// </summary>
public class QueueStatusResponse
{
public bool Success { get; set; }
public string? Message { get; set; }
public List<JobStatus> Jobs { get; set; } = new();
}
/// <summary>
/// Status of a single job
/// </summary>
public class JobStatus
{
public string Id { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public string DocumentType { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string? PrinterName { get; set; }
public int? CurrentPage { get; set; }
public int? TotalPages { get; set; }
}
+175
View File
@@ -0,0 +1,175 @@
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>
/// Get all jobs in the queue (snapshot)
/// </summary>
public PrintJob[] GetAllJobs()
{
return _queue.ToArray();
}
/// <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);
}
}
+38
View File
@@ -0,0 +1,38 @@
namespace PrintServiceTray;
partial class Form1
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
this.components = new System.ComponentModel.Container();
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(800, 450);
this.Text = "Form1";
}
#endregion
}
+9
View File
@@ -0,0 +1,9 @@
namespace PrintServiceTray;
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
}
+515
View File
@@ -0,0 +1,515 @@
using PrintServiceTray.Models;
using PrintServiceTray.Services;
namespace PrintServiceTray.Forms;
public partial class ConfigurationForm : Form
{
private readonly ConfigurationService _configService;
private PrinterConfiguration _config;
private string? _selectedDocType;
private List<string> _installedPrinters;
// UI Controls
private ListBox _docTypeListBox = null!;
private Button _newDocTypeButton = null!;
private Button _deleteDocTypeButton = null!;
private Panel _pageConfigPanel = null!;
private RadioButton _saveGlobalRadio = null!;
private RadioButton _saveLocalRadio = null!;
private Button _saveButton = null!;
private Button _cancelButton = null!;
private Button _addPageButton = null!;
private Button _removePageButton = null!;
private Label _configLabel = null!;
private List<PageConfigControl> _pageControls = new();
public ConfigurationForm()
{
_configService = new ConfigurationService();
_config = _configService.Load();
_installedPrinters = _configService.GetInstalledPrinters();
InitializeComponent();
LoadDocumentTypes();
}
private void InitializeComponent()
{
Text = "LAAPC Printer Configuration";
Size = new Size(900, 600);
StartPosition = FormStartPosition.CenterScreen;
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
// Left panel - Document Types
var leftPanel = new Panel
{
Location = new Point(10, 10),
Size = new Size(250, 540),
BorderStyle = BorderStyle.FixedSingle
};
var docTypeLabel = new Label
{
Text = "Document Types:",
Location = new Point(10, 10),
Size = new Size(230, 20),
Font = new Font(Font, FontStyle.Bold)
};
_docTypeListBox = new ListBox
{
Location = new Point(10, 35),
Size = new Size(230, 430)
};
_docTypeListBox.SelectedIndexChanged += DocTypeListBox_SelectedIndexChanged;
_newDocTypeButton = new Button
{
Text = "New",
Location = new Point(10, 475),
Size = new Size(110, 30)
};
_newDocTypeButton.Click += NewDocType_Click;
_deleteDocTypeButton = new Button
{
Text = "Delete",
Location = new Point(130, 475),
Size = new Size(110, 30)
};
_deleteDocTypeButton.Click += DeleteDocType_Click;
leftPanel.Controls.AddRange(new Control[] {
docTypeLabel, _docTypeListBox, _newDocTypeButton, _deleteDocTypeButton
});
// Right panel - Page Configuration
var rightPanel = new Panel
{
Location = new Point(270, 10),
Size = new Size(610, 540),
BorderStyle = BorderStyle.FixedSingle
};
_configLabel = new Label
{
Text = "Configuration for: (Select a document type)",
Location = new Point(10, 10),
Size = new Size(590, 20),
Font = new Font(Font, FontStyle.Bold)
};
var pageConfigLabel = new Label
{
Text = "Page Configuration:",
Location = new Point(10, 40),
Size = new Size(590, 20)
};
_pageConfigPanel = new Panel
{
Location = new Point(10, 65),
Size = new Size(590, 350),
AutoScroll = true,
BorderStyle = BorderStyle.FixedSingle
};
_addPageButton = new Button
{
Text = "+ Add Page",
Location = new Point(10, 425),
Size = new Size(120, 30)
};
_addPageButton.Click += AddPage_Click;
_removePageButton = new Button
{
Text = "- Remove Last",
Location = new Point(140, 425),
Size = new Size(120, 30)
};
_removePageButton.Click += RemovePage_Click;
// Save options
var saveToLabel = new Label
{
Text = "Save to:",
Location = new Point(10, 465),
Size = new Size(60, 20)
};
_saveGlobalRadio = new RadioButton
{
Text = "Global (All Users)",
Location = new Point(80, 465),
Size = new Size(150, 20)
};
_saveLocalRadio = new RadioButton
{
Text = "Local (My Computer)",
Location = new Point(240, 465),
Size = new Size(170, 20),
Checked = true
};
_saveButton = new Button
{
Text = "Save",
Location = new Point(430, 495),
Size = new Size(80, 35)
};
_saveButton.Click += Save_Click;
_cancelButton = new Button
{
Text = "Cancel",
Location = new Point(520, 495),
Size = new Size(80, 35)
};
_cancelButton.Click += (s, e) => Close();
rightPanel.Controls.AddRange(new Control[] {
_configLabel, pageConfigLabel, _pageConfigPanel,
_addPageButton, _removePageButton,
saveToLabel, _saveGlobalRadio, _saveLocalRadio,
_saveButton, _cancelButton
});
Controls.AddRange(new Control[] { leftPanel, rightPanel });
}
private void LoadDocumentTypes()
{
_docTypeListBox.Items.Clear();
foreach (var docType in _config.DocumentTypes.Keys.OrderBy(k => k))
{
_docTypeListBox.Items.Add(docType);
}
}
private void DocTypeListBox_SelectedIndexChanged(object? sender, EventArgs e)
{
if (_docTypeListBox.SelectedItem is string docType)
{
_selectedDocType = docType;
LoadPageConfiguration(docType);
}
}
private void LoadPageConfiguration(string docType)
{
_configLabel.Text = $"Configuration for: {docType}";
_pageConfigPanel.Controls.Clear();
_pageControls.Clear();
if (!_config.DocumentTypes.TryGetValue(docType, out var config))
{
config = new DocumentTypeConfig { Name = docType };
_config.DocumentTypes[docType] = config;
}
int yPos = 10;
foreach (var page in config.Pages.OrderBy(p => p.PageNumber))
{
var pageControl = new PageConfigControl(page.PageNumber, _installedPrinters, _configService);
pageControl.Location = new Point(10, yPos);
pageControl.LoadPage(page);
_pageConfigPanel.Controls.Add(pageControl);
_pageControls.Add(pageControl);
yPos += pageControl.Height + 10;
}
if (config.Pages.Count == 0)
{
// Add first page by default
AddPage_Click(null, EventArgs.Empty);
}
}
private void AddPage_Click(object? sender, EventArgs e)
{
if (_selectedDocType == null) return;
var config = _config.DocumentTypes[_selectedDocType];
int nextPageNum = config.Pages.Count > 0 ? config.Pages.Max(p => p.PageNumber) + 1 : 1;
var newPage = new PageConfig
{
PageNumber = nextPageNum,
PrinterName = _installedPrinters.FirstOrDefault() ?? "",
TrayNumber = 1
};
config.Pages.Add(newPage);
LoadPageConfiguration(_selectedDocType);
}
private void RemovePage_Click(object? sender, EventArgs e)
{
if (_selectedDocType == null) return;
var config = _config.DocumentTypes[_selectedDocType];
if (config.Pages.Count > 0)
{
var lastPage = config.Pages.OrderByDescending(p => p.PageNumber).First();
config.Pages.Remove(lastPage);
LoadPageConfiguration(_selectedDocType);
}
}
private void NewDocType_Click(object? sender, EventArgs e)
{
var dialog = new TextInputDialog("New Document Type", "Enter document type name:");
if (dialog.ShowDialog() == DialogResult.OK && !string.IsNullOrWhiteSpace(dialog.InputText))
{
var docType = dialog.InputText.Trim();
if (!_config.DocumentTypes.ContainsKey(docType))
{
_config.DocumentTypes[docType] = new DocumentTypeConfig { Name = docType };
LoadDocumentTypes();
_docTypeListBox.SelectedItem = docType;
}
else
{
MessageBox.Show($"Document type '{docType}' already exists.", "Duplicate", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
}
private void DeleteDocType_Click(object? sender, EventArgs e)
{
if (_selectedDocType == null) return;
var result = MessageBox.Show(
$"Are you sure you want to delete '{_selectedDocType}'?",
"Confirm Delete",
MessageBoxButtons.YesNo,
MessageBoxIcon.Question);
if (result == DialogResult.Yes)
{
_config.DocumentTypes.Remove(_selectedDocType);
_selectedDocType = null;
LoadDocumentTypes();
_pageConfigPanel.Controls.Clear();
_configLabel.Text = "Configuration for: (Select a document type)";
}
}
private void Save_Click(object? sender, EventArgs e)
{
try
{
// Save current page configurations
if (_selectedDocType != null && _config.DocumentTypes.TryGetValue(_selectedDocType, out var config))
{
config.Pages.Clear();
foreach (var pageControl in _pageControls)
{
config.Pages.Add(pageControl.GetPageConfig());
}
}
bool saveGlobal = _saveGlobalRadio.Checked;
_configService.Save(_config, saveGlobal);
MessageBox.Show(
$"Configuration saved to {(saveGlobal ? "global" : "local")} file successfully!",
"Success",
MessageBoxButtons.OK,
MessageBoxIcon.Information);
DialogResult = DialogResult.OK;
Close();
}
catch (Exception ex)
{
MessageBox.Show(
$"Failed to save configuration: {ex.Message}",
"Error",
MessageBoxButtons.OK,
MessageBoxIcon.Error);
}
}
}
// Custom control for each page configuration
public class PageConfigControl : UserControl
{
private readonly int _pageNumber;
private readonly List<string> _printers;
private readonly ConfigurationService _configService;
private ComboBox _printerCombo = null!;
private ComboBox _trayCombo = null!;
public PageConfigControl(int pageNumber, List<string> printers, ConfigurationService configService)
{
_pageNumber = pageNumber;
_printers = printers;
_configService = configService;
InitializeComponent();
}
private void InitializeComponent()
{
Size = new Size(560, 60);
BorderStyle = BorderStyle.FixedSingle;
var pageLabel = new Label
{
Text = $"Page {_pageNumber}:",
Location = new Point(10, 10),
Size = new Size(70, 20),
Font = new Font(Font, FontStyle.Bold)
};
var printerLabel = new Label
{
Text = "Printer:",
Location = new Point(10, 32),
Size = new Size(60, 20)
};
_printerCombo = new ComboBox
{
Location = new Point(75, 30),
Size = new Size(250, 25),
DropDownStyle = ComboBoxStyle.DropDownList
};
_printerCombo.Items.AddRange(_printers.ToArray());
_printerCombo.SelectedIndexChanged += PrinterCombo_SelectedIndexChanged;
var trayLabel = new Label
{
Text = "Tray:",
Location = new Point(340, 32),
Size = new Size(40, 20)
};
_trayCombo = new ComboBox
{
Location = new Point(385, 30),
Size = new Size(165, 25),
DropDownStyle = ComboBoxStyle.DropDownList
};
Controls.AddRange(new Control[] {
pageLabel, printerLabel, _printerCombo, trayLabel, _trayCombo
});
}
public void LoadPage(PageConfig page)
{
_printerCombo.SelectedItem = page.PrinterName;
LoadTrays(page.PrinterName);
// Try to find and select the tray
for (int i = 0; i < _trayCombo.Items.Count; i++)
{
if (_trayCombo.Items[i] is TrayItem item && item.TrayNumber == page.TrayNumber)
{
_trayCombo.SelectedIndex = i;
break;
}
}
}
private void PrinterCombo_SelectedIndexChanged(object? sender, EventArgs e)
{
if (_printerCombo.SelectedItem is string printer)
{
LoadTrays(printer);
}
}
private void LoadTrays(string printerName)
{
_trayCombo.Items.Clear();
var trays = _configService.GetPrinterTrays(printerName);
foreach (var (number, name) in trays)
{
_trayCombo.Items.Add(new TrayItem { TrayNumber = number, TrayName = name });
}
if (_trayCombo.Items.Count > 0)
{
_trayCombo.SelectedIndex = 0;
}
}
public PageConfig GetPageConfig()
{
var selectedTray = _trayCombo.SelectedItem as TrayItem;
return new PageConfig
{
PageNumber = _pageNumber,
PrinterName = _printerCombo.SelectedItem?.ToString() ?? "",
TrayNumber = selectedTray?.TrayNumber ?? 1,
TrayLabel = selectedTray?.TrayName
};
}
private class TrayItem
{
public int TrayNumber { get; set; }
public string TrayName { get; set; } = "";
public override string ToString() => $"Tray {TrayNumber} - {TrayName}";
}
}
// Simple text input dialog
public class TextInputDialog : Form
{
private TextBox _textBox = null!;
public string InputText => _textBox.Text;
public TextInputDialog(string title, string prompt)
{
Text = title;
Size = new Size(400, 150);
StartPosition = FormStartPosition.CenterParent;
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
var promptLabel = new Label
{
Text = prompt,
Location = new Point(20, 20),
Size = new Size(360, 20)
};
_textBox = new TextBox
{
Location = new Point(20, 50),
Size = new Size(360, 25)
};
var okButton = new Button
{
Text = "OK",
DialogResult = DialogResult.OK,
Location = new Point(220, 85),
Size = new Size(75, 30)
};
var cancelButton = new Button
{
Text = "Cancel",
DialogResult = DialogResult.Cancel,
Location = new Point(305, 85),
Size = new Size(75, 30)
};
AcceptButton = okButton;
CancelButton = cancelButton;
Controls.AddRange(new Control[] { promptLabel, _textBox, okButton, cancelButton });
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace PrintServiceTray.Models;
/// <summary>
/// Configuration for all document types
/// </summary>
public class PrinterConfiguration
{
public Dictionary<string, DocumentTypeConfig> DocumentTypes { get; set; } = new();
}
/// <summary>
/// Configuration for a single document type
/// </summary>
public class DocumentTypeConfig
{
public string Name { get; set; } = string.Empty;
public List<PageConfig> Pages { get; set; } = new();
}
/// <summary>
/// Configuration for a single page
/// </summary>
public class PageConfig
{
public int PageNumber { get; set; }
public string PrinterName { get; set; } = string.Empty;
public int TrayNumber { get; set; }
public string? TrayLabel { get; set; } // Optional: "Pink Paper", "Green Paper", etc.
}
@@ -0,0 +1,6 @@
namespace PrintServiceTray.Models;
public class QueueStatusRequest
{
public string Command { get; set; } = "GetQueueStatus";
}
@@ -0,0 +1,19 @@
namespace PrintServiceTray.Models;
public class QueueStatusResponse
{
public bool Success { get; set; }
public string? Message { get; set; }
public List<JobStatus> Jobs { get; set; } = new();
}
public class JobStatus
{
public string Id { get; set; } = string.Empty;
public string FileName { get; set; } = string.Empty;
public string DocumentType { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string? PrinterName { get; set; }
public int? CurrentPage { get; set; }
public int? TotalPages { get; set; }
}
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net7.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<SuppressTfmSupportBuildWarnings>true</SuppressTfmSupportBuildWarnings>
</PropertyGroup>
<ItemGroup>
<RuntimeHostConfigurationOption Include="System.Drawing.EnableWindowsSupport" Value="true" />
</ItemGroup>
</Project>
+35
View File
@@ -0,0 +1,35 @@
namespace PrintServiceTray;
static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main()
{
try
{
// Prevent multiple instances
bool createdNew;
using var mutex = new Mutex(true, "LAAPC_PrintServiceTray_Mutex", out createdNew);
if (!createdNew)
{
MessageBox.Show("LAAPC Print Service Tray is already running.", "Already Running",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
ApplicationConfiguration.Initialize();
Application.Run(new TrayApplicationContext());
GC.KeepAlive(mutex);
}
catch (Exception ex)
{
MessageBox.Show($"Fatal error: {ex.Message}\n\n{ex.StackTrace}", "Error",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
}
@@ -0,0 +1,136 @@
using System.Text.Json;
using PrintServiceTray.Models;
namespace PrintServiceTray.Services;
public class ConfigurationService
{
private readonly string _globalConfigPath;
private readonly string _localConfigPath;
public ConfigurationService()
{
// Global config: next to CLI executable (C:\LAAPC or wherever installed)
var cliPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"LAAPC"
);
_globalConfigPath = Path.Combine(cliPath, "printer-config.json");
// Local config: user's AppData
var appDataPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LAAPC"
);
Directory.CreateDirectory(appDataPath);
_localConfigPath = Path.Combine(appDataPath, "printer-config.json");
}
/// <summary>
/// Load configuration (local overrides global)
/// </summary>
public PrinterConfiguration Load()
{
var config = new PrinterConfiguration();
// Load global first
if (File.Exists(_globalConfigPath))
{
try
{
var json = File.ReadAllText(_globalConfigPath);
var globalConfig = JsonSerializer.Deserialize<PrinterConfiguration>(json);
if (globalConfig != null)
{
config = globalConfig;
}
}
catch { }
}
// Load local and merge/override
if (File.Exists(_localConfigPath))
{
try
{
var json = File.ReadAllText(_localConfigPath);
var localConfig = JsonSerializer.Deserialize<PrinterConfiguration>(json);
if (localConfig != null)
{
// Merge: local overrides global for matching document types
foreach (var kvp in localConfig.DocumentTypes)
{
config.DocumentTypes[kvp.Key] = kvp.Value;
}
}
}
catch { }
}
return config;
}
/// <summary>
/// Save configuration to global or local
/// </summary>
public void Save(PrinterConfiguration config, bool saveGlobal)
{
var path = saveGlobal ? _globalConfigPath : _localConfigPath;
// Ensure directory exists
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
var options = new JsonSerializerOptions
{
WriteIndented = true
};
var json = JsonSerializer.Serialize(config, options);
File.WriteAllText(path, json);
}
/// <summary>
/// Get all installed printers
/// </summary>
public List<string> GetInstalledPrinters()
{
var printers = new List<string>();
try
{
foreach (string printerName in System.Drawing.Printing.PrinterSettings.InstalledPrinters)
{
printers.Add(printerName);
}
}
catch { }
return printers;
}
/// <summary>
/// Get available trays for a printer
/// </summary>
public List<(int Number, string Name)> GetPrinterTrays(string printerName)
{
var trays = new List<(int, string)>();
try
{
var printerSettings = new System.Drawing.Printing.PrinterSettings
{
PrinterName = printerName
};
foreach (System.Drawing.Printing.PaperSource source in printerSettings.PaperSources)
{
trays.Add((source.RawKind, source.SourceName));
}
}
catch { }
return trays;
}
public string GlobalConfigPath => _globalConfigPath;
public string LocalConfigPath => _localConfigPath;
}
@@ -0,0 +1,62 @@
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using PrintServiceTray.Models;
namespace PrintServiceTray.Services;
public class ServiceClient
{
private readonly string _pipeName;
private readonly int _timeout;
public ServiceClient(string pipeName = "PrintServicePipe", int timeoutMs = 5000)
{
_pipeName = pipeName;
_timeout = timeoutMs;
}
public async Task<QueueStatusResponse> GetQueueStatusAsync()
{
try
{
using var pipe = new NamedPipeClientStream(".", _pipeName, PipeDirection.InOut);
await pipe.ConnectAsync(_timeout);
var request = new QueueStatusRequest();
var requestJson = JsonSerializer.Serialize(request);
var requestBytes = Encoding.UTF8.GetBytes(requestJson);
await pipe.WriteAsync(requestBytes);
await pipe.FlushAsync();
var buffer = new byte[4096];
var bytesRead = await pipe.ReadAsync(buffer);
var responseJson = Encoding.UTF8.GetString(buffer, 0, bytesRead);
var response = JsonSerializer.Deserialize<QueueStatusResponse>(responseJson);
return response ?? new QueueStatusResponse { Success = false, Message = "Invalid response" };
}
catch (TimeoutException)
{
return new QueueStatusResponse { Success = false, Message = "Service not responding" };
}
catch (Exception ex)
{
return new QueueStatusResponse { Success = false, Message = $"Error: {ex.Message}" };
}
}
public async Task<bool> IsServiceRunningAsync()
{
try
{
var response = await GetQueueStatusAsync();
return response.Success;
}
catch
{
return false;
}
}
}
+206
View File
@@ -0,0 +1,206 @@
using System.Diagnostics;
using PrintServiceTray.Services;
using PrintServiceTray.Models;
namespace PrintServiceTray;
public class TrayApplicationContext : ApplicationContext
{
private readonly NotifyIcon _trayIcon;
private readonly ServiceClient _serviceClient;
private readonly System.Windows.Forms.Timer _refreshTimer;
private readonly ToolStripMenuItem _queueStatusItem;
private readonly ToolStripMenuItem _serviceStatusItem;
public TrayApplicationContext()
{
try
{
_serviceClient = new ServiceClient();
// Create context menu
var contextMenu = new ContextMenuStrip();
// Service status (at top)
_serviceStatusItem = new ToolStripMenuItem("● Service Running")
{
Enabled = false,
Font = new Font(contextMenu.Font, FontStyle.Bold)
};
contextMenu.Items.Add(_serviceStatusItem);
contextMenu.Items.Add(new ToolStripSeparator());
// Queue status section
_queueStatusItem = new ToolStripMenuItem("Queue Status")
{
Enabled = false
};
contextMenu.Items.Add(_queueStatusItem);
var viewQueueItem = new ToolStripMenuItem("View Full Queue...");
viewQueueItem.Click += ViewQueue_Click;
contextMenu.Items.Add(viewQueueItem);
contextMenu.Items.Add(new ToolStripSeparator());
// Configuration
var configureItem = new ToolStripMenuItem("Configure Printers...");
configureItem.Click += Configure_Click;
contextMenu.Items.Add(configureItem);
contextMenu.Items.Add(new ToolStripSeparator());
// Exit
var exitItem = new ToolStripMenuItem("Exit");
exitItem.Click += Exit_Click;
contextMenu.Items.Add(exitItem);
// Create tray icon
_trayIcon = new NotifyIcon
{
Icon = SystemIcons.Information,
ContextMenuStrip = contextMenu,
Visible = true,
Text = "LAAPC Print Service"
};
_trayIcon.DoubleClick += TrayIcon_DoubleClick;
// Show startup notification
_trayIcon.ShowBalloonTip(2000, "LAAPC Print Service", "Tray app started", ToolTipIcon.Info);
// Set up refresh timer (every 2 seconds)
_refreshTimer = new System.Windows.Forms.Timer
{
Interval = 2000
};
_refreshTimer.Tick += RefreshTimer_Tick;
_refreshTimer.Start();
// Initial refresh
_ = RefreshStatusAsync();
}
catch (Exception ex)
{
MessageBox.Show($"Failed to initialize tray app: {ex.Message}\n\n{ex.StackTrace}",
"Initialization Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
throw;
}
}
private async void RefreshTimer_Tick(object? sender, EventArgs e)
{
await RefreshStatusAsync();
}
private async Task RefreshStatusAsync()
{
try
{
var status = await _serviceClient.GetQueueStatusAsync();
if (status.Success)
{
_serviceStatusItem.Text = "● Service Running";
_serviceStatusItem.ForeColor = Color.Green;
// Update queue status
if (status.Jobs.Count == 0)
{
_queueStatusItem.Text = "Queue Status: Empty";
_queueStatusItem.DropDownItems.Clear();
}
else
{
_queueStatusItem.Text = $"Queue Status ({status.Jobs.Count} {(status.Jobs.Count == 1 ? "job" : "jobs")})";
_queueStatusItem.Enabled = true;
// Update dropdown items with job list
_queueStatusItem.DropDownItems.Clear();
foreach (var job in status.Jobs.Take(10)) // Show max 10 in menu
{
var statusIcon = job.Status == "Processing" ? "●" : "○";
var jobText = $"{statusIcon} {job.Status}: {job.FileName}";
if (job.PrinterName != null)
{
jobText += $" ({job.PrinterName})";
}
if (job.CurrentPage.HasValue && job.TotalPages.HasValue)
{
jobText += $" - Page {job.CurrentPage}/{job.TotalPages}";
}
var jobItem = new ToolStripMenuItem(jobText) { Enabled = false };
_queueStatusItem.DropDownItems.Add(jobItem);
}
if (status.Jobs.Count > 10)
{
_queueStatusItem.DropDownItems.Add(new ToolStripSeparator());
var moreItem = new ToolStripMenuItem($"... and {status.Jobs.Count - 10} more")
{
Enabled = false
};
_queueStatusItem.DropDownItems.Add(moreItem);
}
}
// Update tooltip
_trayIcon.Text = status.Jobs.Count == 0
? "LAAPC Print Service - Queue empty"
: $"LAAPC Print Service - {status.Jobs.Count} job(s) in queue";
}
else
{
_serviceStatusItem.Text = "○ Service Not Running";
_serviceStatusItem.ForeColor = Color.Red;
_queueStatusItem.Text = "Queue Status: Unavailable";
_queueStatusItem.DropDownItems.Clear();
_trayIcon.Text = "LAAPC Print Service - Not running";
}
}
catch (Exception ex)
{
Debug.WriteLine($"Error refreshing status: {ex.Message}");
}
}
private void TrayIcon_DoubleClick(object? sender, EventArgs e)
{
// Double-click opens full queue view
ViewQueue_Click(sender, e);
}
private void ViewQueue_Click(object? sender, EventArgs e)
{
// TODO: Open queue view window
MessageBox.Show("Queue view window coming soon!", "LAAPC Print Service", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
private void Configure_Click(object? sender, EventArgs e)
{
var configForm = new Forms.ConfigurationForm();
configForm.ShowDialog();
}
private void Exit_Click(object? sender, EventArgs e)
{
_refreshTimer.Stop();
_trayIcon.Visible = false;
Application.Exit();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_refreshTimer?.Stop();
_refreshTimer?.Dispose();
_trayIcon?.Dispose();
}
base.Dispose(disposing);
}
}
+283 -2
View File
@@ -1,2 +1,283 @@
# CGW-Printing # 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]
+240
View File
@@ -0,0 +1,240 @@
# LAAPC Print Service - Remaining Work
## ✅ Completed
- [x] Core service architecture (queue, IPC, file monitoring)
- [x] CLI with self-installation capability
- [x] Basic printing with tray control
- [x] PDF output for testing
- [x] System tray application with status display
- [x] Configuration dialog for printer/tray assignments
- [x] Fixed System.Drawing.Common platform compatibility
## 🅿️ Parking Lot - Configuration Dialog Issues
### Known Issues (Non-Critical)
- [ ] **Printer resets on add page:** When adding a new page to a document type, the printer dropdown resets to the first printer in the list if the configuration hasn't been saved yet
- Expected: Should remember previous page's printer selection or default intelligently
- Workaround: Save after configuring each page
- [ ] **Save closes dialog:** Clicking Save closes the configuration dialog, forcing users to reopen it to continue editing
- Expected: Save button should persist changes but keep dialog open
- Suggested: Add "Save & Close" and "Save" as separate buttons, or just make Save not close the dialog
## 🔲 Immediate Testing
### Test Configuration Dialog
- [ ] Right-click tray icon → "Configure Printers..."
- [ ] Create new document type
- [ ] Add pages with printer/tray assignments
- [ ] Save configuration (test both local and global)
- [ ] Verify printer-config.json file created correctly
- [ ] Test loading existing configuration
## 🔨 High Priority - Service Integration
### 1. Integrate printer-config.json into PrintService
**Goal:** Service should read printer-config.json instead of appsettings.json
**Files to modify:**
- `PrintService/Services/ConfigurationService.cs` (CREATE NEW)
- Load printer-config.json from C:\ProgramData\LAAPC\ (global) and %LOCALAPPDATA%\LAAPC\ (local)
- Merge local over global (same logic as tray app)
- Return `PrinterConfiguration` object
- `PrintService/Models/PrinterConfig.cs` (COPY from PrintServiceTray)
- Copy the new config models into PrintService project
- `PrintService/Worker.cs` (MODIFY)
- Replace DocumentConfig loading with PrinterConfiguration loading
- Pass PageConfig[] to DocumentProcessor
- `PrintService/Services/DocumentProcessor.cs` (MODIFY - or DELETE if not needed)
- Update to work with new PageConfig format
- May need to refactor logic
- `PrintService/Services/PrinterService.cs` (MODIFY)
- Update Print() method signature to accept PageConfig[]
- Print each page to correct printer/tray based on PageConfig
**Notes:**
- Keep appsettings.json for service-level settings (queue path, retry counts, etc.)
- printer-config.json ONLY for document type → printer/tray mappings
- Support missing config gracefully (log warning, skip job)
## 🔨 High Priority - Page Duplication Pattern
### 2. Implement Multi-Tray Page Duplication
**User Requirement:** Print EACH original page to ALL trays in sequence
**Current Behavior:**
```
TraySequence: [3,4,1,2]
Original document: 4 pages
Output: 4 pages (page 1→tray3, page 2→tray4, page 3→tray1, page 4→tray2)
```
**New Behavior:**
```
TraySequence: [3,4,1,2]
Original document: 4 pages
Output: 16 pages
Page 1 → Tray 3
Page 1 → Tray 4
Page 1 → Tray 1
Page 1 → Tray 2
Page 2 → Tray 3
Page 2 → Tray 4
Page 2 → Tray 1
Page 2 → Tray 2
... (and so on)
```
**Files to modify:**
- `PrintService/Services/PrinterService.cs`
- Modify PrintPage event handler
- Add page duplication logic
- Track: originalPageIndex, trayIndex, currentOutputPage
- For each original page, cycle through all trays before moving to next page
**Algorithm:**
```csharp
int originalPageIndex = 0;
int trayIndex = 0;
int totalOriginalPages = CalculateTotalPages(lines);
bool hasMorePages = true;
PrintPage event:
1. Render lines for originalPageIndex
2. Set tray to pageConfigs[originalPageIndex].TrayNumber
3. Increment trayIndex
4. If trayIndex >= trays.Length:
trayIndex = 0
originalPageIndex++
5. hasMorePages = (originalPageIndex < totalOriginalPages)
```
## 🔨 Medium Priority - Content Modifications
### 3. Port Rust Content Transformation Logic
**Reference:** `RUST/cgwprint/src/background.rs` and `RUST/cgwprint/data/printers.json`
**DO NOT START UNTIL:** Tray config and page duplication are complete and tested
**New config fields needed in PageConfig:**
```json
{
"PageNumber": 1,
"PrinterName": "Brother HL-L6415DW",
"TrayNumber": 3,
"TrayLabel": "Pink",
// NEW FIELDS:
"RemoveOrderNumber": false,
"RemoveOrderNumberLabel": false,
"IndentOrderNumber": 0,
"RowShift": {
"5": -2, // Move line 5 up by 2 positions
"12": 1 // Move line 12 down by 1 position
},
"RowTrim": {
"3": { "Start": 0, "End": 40 }, // Keep only first 40 chars of line 3
"7": { "Start": 10, "End": 50 } // Keep chars 10-50 of line 7
},
"BoldFont": "Courier New Bold",
"NormalFont": "Courier New"
}
```
**Processing steps:**
1. Parse hex escapes (\xHH → bytes)
2. Split on form feed (\x0C) into pages
3. Font replacement (ESC+w+1 → bold, ESC+w+0 → normal)
4. Order number manipulation (remove/indent based on config)
5. Apply row_shift (vertical position adjustments)
6. Apply row_trim (substring extraction)
7. Add PCL/PJL header/footer wrapping
**Files to modify:**
- `PrintService/Models/PageConfig.cs` - Add new properties
- `PrintService/Services/DocumentProcessor.cs` - Add content transformation
- `PrintService/Services/ContentTransformer.cs` (CREATE NEW)
- ParseHexEscapes()
- SplitPages()
- ProcessFontCodes()
- ManipulateOrderNumber()
- ApplyRowShift()
- ApplyRowTrim()
- WrapWithPclHeaders()
**Location-specific variations:**
- "there are 3 locations where this app is used and each has a slight different layout"
- Use document type naming to differentiate: "Invoice_Location1", "Invoice_Location2", etc.
- Or add "Location" field to config and filter by it
## 📋 Testing Checklist
### Before Production Deployment
- [ ] Test service installation on clean 32-bit Windows 10 machine
- [ ] Test CLI from Harbor/Clipper integration
- [ ] Test rapid concurrent print requests (race condition prevention)
- [ ] Test multi-tray printing on Brother HL-L6415DW
- [ ] Test configuration changes without service restart
- [ ] Test queue persistence (stop service mid-job, restart, verify resume)
- [ ] Test error handling (printer offline, invalid tray, etc.)
- [ ] Test tray app startup on Windows boot (add to Startup folder?)
- [ ] Verify file cleanup after successful print
- [ ] Verify error folder gets populated on failures
## 🎯 Future Enhancements (Low Priority)
- [ ] Add logging to file for tray app (currently only service logs)
- [ ] Add "Start/Stop Service" option in tray menu
- [ ] Add "View Logs" option in tray menu
- [ ] Add notification sound for completed jobs
- [ ] Add job history view (last 50 jobs)
- [ ] Support per-page margins configuration
- [ ] Support custom font sizes per document type
- [ ] Add web UI for remote monitoring
## 📝 Important Notes
### Platform Compatibility
- **System.Drawing.Common:** Requires RuntimeHostConfigurationOption in .csproj
- **Remove explicit package reference** to avoid startup errors
- WinForms apps automatically include System.Drawing on Windows
### Configuration Locations
- **Global:** `C:\ProgramData\LAAPC\printer-config.json` (shared, requires admin)
- **Local:** `%LOCALAPPDATA%\LAAPC\printer-config.json` (per-user, no admin)
- **Merge strategy:** Local settings override global settings by document type
### Print Service Config
- Service currently uses: `appsettings.json` (old format with DocumentConfig)
- Service needs to migrate to: `printer-config.json` (new format with PageConfig[])
- Keep `appsettings.json` for non-printer settings (QueuePath, MaxRetries, etc.)
### Page Duplication Logic
- Current: One page per tray (simple mapping)
- Required: All pages to all trays (page duplication with tray cycling)
- Example: 4-page invoice × 5 trays = 20 physical pages printed
- Reason: Simulates carbon copy paper (each page needs copy in each color)
### Content Modifications
- Do LAST (most complex, least critical for initial deployment)
- Three locations have slightly different requirements
- May need per-location configuration or document type naming convention
- PCL/PJL wrapping may only apply to specific printers
### Git Status
- Last commit: cce9248
- `RUST/` folder excluded from version control
- Remember to commit after each major milestone
## 🚀 Next Steps (In Order)
1. **RIGHT NOW:** Test configuration dialog in tray app
2. Integrate printer-config.json into service
3. Implement page duplication pattern
4. Test on production hardware (Brother HL-L6415DW)
5. Deploy to first location
6. Gather feedback
7. Port content modifications from Rust
8. Deploy to remaining two locations
+1
View File
@@ -0,0 +1 @@
[]