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.
This commit is contained in:
Jason
2026-05-29 10:43:48 -05:00
parent cce9248089
commit a08da0217d
17 changed files with 1541 additions and 4 deletions
+3
View File
@@ -10,6 +10,9 @@ Archive/
Error/
Output/
# Rust Project (reference implementation)
RUST/
# User-specific files
*.suo
*.user
+114 -4
View File
@@ -150,7 +150,8 @@
"label": "build-all-x86",
"dependsOn": [
"build-service-release-x86",
"build-cli-release-x86"
"build-cli-release-x86",
"build-tray-release-x86"
],
"dependsOrder": "parallel",
"group": {
@@ -163,7 +164,8 @@
"label": "build-all-x64",
"dependsOn": [
"build-service-release-x64",
"build-cli-release-x64"
"build-cli-release-x64",
"build-tray-release-x64"
],
"dependsOrder": "parallel",
"group": "build",
@@ -270,7 +272,8 @@
"label": "publish-all-x86",
"dependsOn": [
"publish-service-x86-self-contained",
"publish-cli-x86-self-contained"
"publish-cli-x86-self-contained",
"publish-tray-x86-self-contained"
],
"dependsOrder": "parallel",
"group": "build",
@@ -280,7 +283,8 @@
"label": "publish-all-x64",
"dependsOn": [
"publish-service-x64-self-contained",
"publish-cli-x64-self-contained"
"publish-cli-x64-self-contained",
"publish-tray-x64-self-contained"
],
"dependsOrder": "parallel",
"group": "build",
@@ -354,6 +358,112 @@
"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
// ============================================
{
+6
View File
@@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PrintService", "PrintServic
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
@@ -24,5 +26,9 @@ Global
{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
+99
View File
@@ -122,6 +122,17 @@ public class IpcService : IDisposable
_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)
{
@@ -163,6 +174,70 @@ public class IpcService : IDisposable
}
}
/// <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>
@@ -239,3 +314,27 @@ 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; }
}
@@ -59,6 +59,14 @@ public class PrintQueueService
/// </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>
+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);
}
}
+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