4 Commits

15 changed files with 724 additions and 41 deletions
+1 -4
View File
@@ -75,11 +75,8 @@ jobs:
dotnet publish src/LabelPrintAgent/LabelPrintAgent.csproj \
--configuration Release \
--runtime win-x64 \
--self-contained true \
--self-contained false \
--output publish/win-x64 \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \
-p:Version="$version" \
-p:InformationalVersion="$tag+$GITHUB_SHA"
+44 -7
View File
@@ -12,7 +12,7 @@ Alle Endpunkte erfordern einen Bearer Token (JWT oder API-Key):
Authorization: Bearer {token}
```
`/jobs/next`, `/jobs/:id/image`, `/jobs/:id/printed` und `/jobs/:id/error` benötigen keine spezifische Permission, nur einen gültigen Token. `POST /jobs` und `POST /preview` erfordern `VIEW_SCANNER`.
`/printers/register`, `/jobs/next`, `/jobs/:id/image`, `/jobs/:id/printed` und `/jobs/:id/error` benötigen keine spezifische Permission, nur einen gültigen Token. `POST /jobs` und `POST /preview` erfordern `VIEW_SCANNER`.
## 1. Job manuell anlegen (Frontend -> Backend)
@@ -67,7 +67,38 @@ Content-Type: image/png
Body: binäres PNG-Bild.
## 3. Nächsten Druckjob abrufen (Agent-Polling)
## 3. Drucker registrieren (Benutzeraktion in den Agent-Einstellungen)
Der Agent ruft diesen Endpunkt nicht automatisch beim Start auf. Der Benutzer registriert die freigegebenen lokalen Windows-Drucker bewusst über die Agent-Einstellungen.
```http
POST /api/label-print-agent/printers/register
Content-Type: application/json
```
```json
{
"printerId": "PC-BUERO_ZEBRA_GK420D",
"agentId": "PC-BUERO",
"name": "Zebra GK420 Büro",
"windowsPrinterName": "Zebra GK420d",
"dpi": 203,
"defaultWidthMm": 101,
"defaultHeightMm": 76
}
```
### Antwort
```http
200 OK
```
```json
{ "ok": true }
```
## 4. Nächsten Druckjob abrufen (Agent-Polling)
```http
GET /api/label-print-agent/jobs/next?agentId={agentId}
@@ -92,7 +123,10 @@ Content-Type: application/json
```json
{
"jobId": "42",
"jobId": 42,
"printerId": "PC-BUERO_ZEBRA_GK420D",
"labelType": "artikel",
"windowsPrinterName": "Zebra GK420d",
"labelImageBase64": "iVBORw0KGgoAAAANSUhEUgAA...",
"labelImageContentType": "image/png",
"labelWidthMm": 57,
@@ -103,13 +137,16 @@ Content-Type: application/json
| Feld | Beschreibung |
| --- | --- |
| `jobId` | Job-ID für Rückmeldungen |
| `printerId` | Backend-ID des Zieldruckers |
| `labelType` | Etikettart, z. B. `artikel` oder `text` |
| `windowsPrinterName` | Exakter lokaler Windows-Druckername, auf den der Agent drucken soll |
| `labelImageBase64` | Base64-PNG; `null` wenn Rendering fehlgeschlagen |
| `labelImageContentType` | Immer `image/png` |
| `labelWidthMm` / `labelHeightMm` | Etikettenmaß in mm |
Das Backend setzt beim Ausliefern einen Lock (5-Minuten-TTL). Jobs mit abgelaufenem Lock werden erneut angeboten.
## 4. Etikettbild separat abrufen
## 5. Etikettbild separat abrufen
Alternativ zum Base64-Feld in `jobs/next`.
@@ -132,7 +169,7 @@ Body: binäres PNG-Bild.
Job oder Bild nicht vorhanden.
## 5. Erfolgreichen Druck melden
## 6. Erfolgreichen Druck melden
```http
POST /api/label-print-agent/jobs/{jobId}/printed
@@ -162,7 +199,7 @@ Alle Felder optional; Fallback jeweils `""` / `unknown`.
Das Backend setzt den Job auf `printed`, speichert Zeitstempel und ruft die konfigurierte `LabelPrintedUrl` des Templates auf (`POST`).
## 6. Druckfehler melden
## 7. Druckfehler melden
```http
POST /api/label-print-agent/jobs/{jobId}/error
@@ -206,7 +243,7 @@ createJob()
(PrintedUrl) (ReleaseUrl)
```
## 7. Server-Sent Events neue Druckaufträge (Push)
## 8. Server-Sent Events neue Druckaufträge (Push)
```http
GET /api/label-print-agent/events
+42 -3
View File
@@ -38,7 +38,10 @@ Wenn ein Etikett vorhanden ist:
```json
{
"jobId": "12345",
"jobId": 12345,
"printerId": "PC-BUERO_DYMO_LABELWRITER_450",
"labelType": "artikel",
"windowsPrinterName": "DYMO LabelWriter 450",
"labelImageBase64": "...",
"labelImageContentType": "image/png",
"labelWidthMm": 57,
@@ -50,7 +53,10 @@ Alternativ darf das Backend statt `labelImageBase64` eine URL liefern:
```json
{
"jobId": "12345",
"jobId": 12345,
"printerId": "PC-BUERO_DYMO_LABELWRITER_450",
"labelType": "artikel",
"windowsPrinterName": "DYMO LabelWriter 450",
"labelImageUrl": "/api/label-print-agent/jobs/12345/image",
"labelImageContentType": "image/png",
"labelWidthMm": 57,
@@ -101,6 +107,7 @@ Beispiel:
"baseUrl": "https://paperlessmanager.local",
"agentId": "PC-BUERO",
"encryptedApiToken": "",
"registerPrinterPath": "/api/label-print-agent/printers/register",
"nextJobPath": "/api/label-print-agent/jobs/next",
"imagePath": "/api/label-print-agent/jobs/{jobId}/image",
"reportSuccessPath": "/api/label-print-agent/jobs/{jobId}/printed",
@@ -114,6 +121,24 @@ Beispiel:
"labelHeightMm": 32,
"dpi": 300
},
"printers": [
{
"printerId": "PC-BUERO_DYMO_LABELWRITER_450",
"name": "DYMO LabelWriter Regal",
"windowsPrinterName": "DYMO LabelWriter 450",
"dpi": 300,
"defaultWidthMm": 57,
"defaultHeightMm": 32
},
{
"printerId": "PC-BUERO_ZEBRA_GK420D",
"name": "Zebra GK420 Büro",
"windowsPrinterName": "Zebra GK420d",
"dpi": 203,
"defaultWidthMm": 101,
"defaultHeightMm": 76
}
],
"worker": {
"enabled": true,
"pollIntervalSeconds": 30
@@ -123,6 +148,8 @@ Beispiel:
Der API-Token wird lokal mit Windows DPAPI verschlüsselt gespeichert.
Der Agent registriert Drucker nicht automatisch. Im Tab `Drucker` kann der Benutzer die freigegebenen Einträge aus `printers` bewusst mit `Im Backend registrieren` an das Backend melden. Das Feld `printer` bleibt als Kompatibilitätsfeld für ältere Einstellungen erhalten; neue Jobs werden anhand von `windowsPrinterName` aus der Backend-Antwort auf den passenden lokalen Windows-Drucker gedruckt.
## Dymo-Druck
Der Dymo LabelWriter muss in Windows als normaler Drucker eingerichtet sein. Das Backend liefert ein fertiges Bild für das Etikett, typischerweise PNG in `57 x 32 mm`.
@@ -139,10 +166,22 @@ Wichtig:
1. Anwendung starten.
2. Tray-Symbol öffnen.
3. Im Tab `Backend` BaseUrl, AgentId und optional API-Token eintragen.
4. Im Tab `Drucker` den Dymo LabelWriter auswählen.
4. Im Tab `Drucker` die freigegebenen Drucker konfigurieren und mit `Im Backend registrieren` ans Backend melden.
5. Im Tab `Allgemein` Polling aktivieren und Intervall setzen.
6. Mit `Jetzt prüfen` kann sofort ein Poll-Lauf ausgelöst werden; dabei werden alle aktuell verfügbaren Jobs verarbeitet.
## Updates
Im Tab `Updates` kann der Agent gegen die Gitea-Release-API nach neuen Versionen suchen. Standardmäßig wird das neueste Release dieses Repositorys abgefragt:
```text
https://gitea.poettker-cloud.de/api/v1/repos/bjoernpoettker/LabelPrintAgent/releases/latest
```
Für private Repositories kann ein Gitea-Access-Token hinterlegt werden. Der Token wird lokal verschlüsselt gespeichert.
Wenn ein neueres Release gefunden wird, sucht der Agent nach einem ZIP-Asset, bevorzugt mit dem Suffix `-win-x64.zip`. Das ZIP enthält den framework-dependent `win-x64`-Publish inklusive `.exe`, App-Dateien und zugehöriger NuGet-`.dll`-Dateien, aber ohne die normale .NET-Laufzeit. Beim Installieren lädt der Agent das ZIP herunter, entpackt es in ein temporäres Verzeichnis, startet ein lokales Update-Skript, beendet sich selbst, ersetzt die Dateien im Installationsordner und startet anschließend neu.
## Tray-Status
Das Tray-Icon zeigt den aktuellen Zustand:
+3 -1
View File
@@ -2,6 +2,7 @@ using LabelPrintAgent.Backend;
using LabelPrintAgent.Configuration;
using LabelPrintAgent.Logging;
using LabelPrintAgent.Printing;
using LabelPrintAgent.Updates;
using Serilog;
namespace LabelPrintAgent.App;
@@ -23,10 +24,11 @@ internal static class Program
var printerService = new PrinterService();
var backendClient = new BackendClient(settingsStore);
var updateService = new UpdateService(settingsStore);
using var backendWorker = new BackendPollingWorker(settingsStore, backendClient, printerService);
backendWorker.Start();
Application.Run(new TrayApplicationContext(settingsStore, printerService, backendWorker));
Application.Run(new TrayApplicationContext(settingsStore, printerService, backendClient, backendWorker, updateService));
}
catch (Exception ex)
{
+237 -10
View File
@@ -1,6 +1,7 @@
using LabelPrintAgent.Backend;
using LabelPrintAgent.Configuration;
using LabelPrintAgent.Printing;
using LabelPrintAgent.Updates;
using Microsoft.Win32;
using Serilog;
@@ -13,7 +14,10 @@ internal sealed class SettingsForm : Form
private readonly SettingsStore _settingsStore;
private readonly PrinterService _printerService;
private readonly BackendClient _backendClient;
private readonly BackendPollingWorker _backendWorker;
private readonly UpdateService _updateService;
private UpdateCheckResult? _lastUpdateCheck;
private readonly CheckBox _autoStartCheckBox = new() { Text = "Autostart aktivieren", AutoSize = true };
private readonly CheckBox _workerEnabled = new() { Text = "Backend automatisch abfragen", AutoSize = true };
@@ -30,7 +34,15 @@ internal sealed class SettingsForm : Form
private readonly CheckBox _useServerSentEvents = new() { Text = "Server-Sent Events verwenden", AutoSize = true };
private readonly TextBox _eventsPath = new() { Width = 420 };
private readonly TextBox _releaseApiUrl = new() { Width = 540 };
private readonly TextBox _releaseToken = new() { Width = 420, UseSystemPasswordChar = true };
private readonly TextBox _currentVersion = new() { Width = 180, ReadOnly = true };
private readonly TextBox _latestVersion = new() { Width = 180, ReadOnly = true };
private readonly TextBox _updateAsset = new() { Width = 540, ReadOnly = true };
private readonly Button _installUpdateButton;
private readonly ComboBox _printer = new() { Width = 360, DropDownStyle = ComboBoxStyle.DropDownList };
private readonly CheckedListBox _sharedPrinters = new() { Width = 360, Height = 125, CheckOnClick = true };
private readonly NumericUpDown _labelWidth = new() { Minimum = 1, Maximum = 500, DecimalPlaces = 1, Value = 57, Width = 100 };
private readonly NumericUpDown _labelHeight = new() { Minimum = 1, Maximum = 500, DecimalPlaces = 1, Value = 32, Width = 100 };
@@ -42,11 +54,20 @@ internal sealed class SettingsForm : Form
Dock = DockStyle.Fill
};
public SettingsForm(SettingsStore settingsStore, PrinterService printerService, BackendPollingWorker backendWorker)
public SettingsForm(
SettingsStore settingsStore,
PrinterService printerService,
BackendClient backendClient,
BackendPollingWorker backendWorker,
UpdateService updateService)
{
_settingsStore = settingsStore;
_printerService = printerService;
_backendClient = backendClient;
_backendWorker = backendWorker;
_updateService = updateService;
_installUpdateButton = ButtonAt("Update installieren", 340, 265, async (_, _) => await InstallUpdateFromUiAsync());
_installUpdateButton.Enabled = false;
Text = "LabelPrintAgent Einstellungen";
Width = 900;
@@ -57,6 +78,7 @@ internal sealed class SettingsForm : Form
tabs.TabPages.Add(CreateGeneralTab());
tabs.TabPages.Add(CreateBackendTab());
tabs.TabPages.Add(CreatePrinterTab());
tabs.TabPages.Add(CreateUpdatesTab());
tabs.TabPages.Add(CreateStatusTab());
Controls.Add(tabs);
@@ -98,14 +120,36 @@ internal sealed class SettingsForm : Form
private TabPage CreatePrinterTab()
{
var panel = CreatePaddedPanel();
panel.Controls.Add(Row(20, Label("Drucker", 120), _printer));
panel.Controls.Add(Row(60, Label("Breite", 120), _labelWidth, Label("mm", 40)));
panel.Controls.Add(Row(100, Label("Höhe", 120), _labelHeight, Label("mm", 40)));
panel.Controls.Add(ButtonAt("Drucker neu laden", 140, 150, (_, _) => LoadPrinters()));
panel.Controls.Add(ButtonAt("Speichern", 300, 150, (_, _) => SavePrinter(showMessage: true)));
panel.Controls.Add(Row(20, Label("Standarddrucker", 120), _printer));
var sharedLabel = Label("Freigegeben", 120);
sharedLabel.Left = 20;
sharedLabel.Top = 60;
_sharedPrinters.Left = 145;
_sharedPrinters.Top = 60;
panel.Controls.Add(sharedLabel);
panel.Controls.Add(_sharedPrinters);
panel.Controls.Add(Row(200, Label("Breite", 120), _labelWidth, Label("mm", 40)));
panel.Controls.Add(Row(240, Label("Höhe", 120), _labelHeight, Label("mm", 40)));
panel.Controls.Add(ButtonAt("Drucker neu laden", 140, 295, (_, _) => LoadPrinters()));
panel.Controls.Add(ButtonAt("Speichern", 300, 295, (_, _) => SavePrinter(showMessage: true)));
panel.Controls.Add(ButtonAt("Im Backend registrieren", 430, 295, async (_, _) => await RegisterPrintersFromUiAsync()));
return new TabPage("Drucker") { Controls = { panel } };
}
private TabPage CreateUpdatesTab()
{
var panel = CreatePaddedPanel();
panel.Controls.Add(Row(20, Label("Aktuelle Version", 160), _currentVersion));
panel.Controls.Add(Row(60, Label("Neueste Version", 160), _latestVersion));
panel.Controls.Add(Row(100, Label("Release API", 160), _releaseApiUrl));
panel.Controls.Add(Row(140, Label("Access Token", 160), _releaseToken));
panel.Controls.Add(Row(180, Label("Installationsdatei", 160), _updateAsset));
panel.Controls.Add(ButtonAt("Speichern", 180, 265, (_, _) => SaveUpdates(showMessage: true)));
panel.Controls.Add(ButtonAt("Nach Updates suchen", 180, 315, async (_, _) => await CheckForUpdateFromUiAsync()));
panel.Controls.Add(_installUpdateButton);
return new TabPage("Updates") { Controls = { panel } };
}
private TabPage CreateStatusTab()
{
var page = new TabPage("Status");
@@ -131,18 +175,34 @@ internal sealed class SettingsForm : Form
_useServerSentEvents.Checked = settings.Backend.UseServerSentEvents;
_eventsPath.Text = settings.Backend.EventsPath;
_labelWidth.Value = settings.Printer.LabelWidthMm;
_labelHeight.Value = settings.Printer.LabelHeightMm;
_releaseApiUrl.Text = settings.Updates.ReleaseApiUrl;
_releaseToken.Text = _settingsStore.DecryptUpdateAccessToken(settings);
_currentVersion.Text = _updateService.CurrentVersion.ToString();
_latestVersion.Text = string.Empty;
_updateAsset.Text = string.Empty;
_installUpdateButton.Enabled = false;
_lastUpdateCheck = null;
var primaryPrinter = GetPrimaryPrinter(settings);
_labelWidth.Value = primaryPrinter?.DefaultWidthMm ?? settings.Printer.LabelWidthMm;
_labelHeight.Value = primaryPrinter?.DefaultHeightMm ?? settings.Printer.LabelHeightMm;
}
private void LoadPrinters()
{
var selected = _settingsStore.Load().Printer.PrinterName;
var settings = _settingsStore.Load();
var selected = GetPrimaryPrinter(settings)?.WindowsPrinterName ?? settings.Printer.PrinterName;
_printer.Items.Clear();
_sharedPrinters.Items.Clear();
var sharedPrinterNames = settings.Printers
.Select(printer => printer.WindowsPrinterName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var printers = _printerService.GetInstalledPrinters();
foreach (var printer in printers)
{
_printer.Items.Add(printer);
_sharedPrinters.Items.Add(printer, sharedPrinterNames.Contains(printer.Name));
}
var configuredPrinter = printers.FirstOrDefault(printer => string.Equals(printer.Name, selected, StringComparison.OrdinalIgnoreCase));
@@ -188,9 +248,20 @@ internal sealed class SettingsForm : Form
private void SavePrinter(bool showMessage)
{
var settings = _settingsStore.Load();
settings.Printer.PrinterName = GetSelectedPrinterName() ?? string.Empty;
var selectedPrinterName = GetSelectedPrinterName() ?? string.Empty;
var sharedPrinterNames = GetSharedPrinterNames();
settings.Printer.PrinterName = selectedPrinterName;
settings.Printer.LabelWidthMm = _labelWidth.Value;
settings.Printer.LabelHeightMm = _labelHeight.Value;
settings.Printers = sharedPrinterNames
.Select(printerName => BuildPrinterConfig(settings, printerName))
.ToList();
if (settings.Printers.Count == 0 && !string.IsNullOrWhiteSpace(selectedPrinterName))
{
settings.Printers.Add(BuildPrinterConfig(settings, selectedPrinterName));
}
_settingsStore.Save(settings);
AppendStatus("Druckereinstellungen gespeichert.");
if (showMessage)
@@ -199,6 +270,19 @@ internal sealed class SettingsForm : Form
}
}
private void SaveUpdates(bool showMessage)
{
var settings = _settingsStore.Load();
settings.Updates.ReleaseApiUrl = _releaseApiUrl.Text.Trim();
settings.Updates.EncryptedAccessToken = _settingsStore.EncryptPassword(_releaseToken.Text.Trim());
_settingsStore.Save(settings);
AppendStatus("Update-Einstellungen gespeichert.");
if (showMessage)
{
MessageBox.Show("Update-Einstellungen gespeichert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}
private async Task PollOnceFromUiAsync()
{
try
@@ -217,6 +301,111 @@ internal sealed class SettingsForm : Form
}
}
private async Task RegisterPrintersFromUiAsync()
{
try
{
SaveBackend(showMessage: false);
SavePrinter(showMessage: false);
var settings = _settingsStore.Load();
var printerCount = settings.Printers.Count(printer => !string.IsNullOrWhiteSpace(printer.WindowsPrinterName));
if (printerCount == 0)
{
MessageBox.Show("Bitte zuerst mindestens einen Drucker konfigurieren.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
AppendStatus($"{printerCount} Drucker werden im Backend registriert...");
await _backendClient.RegisterPrintersAsync();
AppendStatus($"{printerCount} Drucker im Backend registriert.");
MessageBox.Show("Drucker wurden im Backend registriert.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
Log.Error(ex, "Printer registration failed");
AppendStatus($"Druckerregistrierung fehlgeschlagen: {ex.Message}");
MessageBox.Show($"Druckerregistrierung fehlgeschlagen: {ex.Message}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private async Task CheckForUpdateFromUiAsync()
{
try
{
SaveUpdates(showMessage: false);
_installUpdateButton.Enabled = false;
_lastUpdateCheck = null;
_latestVersion.Text = string.Empty;
_updateAsset.Text = string.Empty;
AppendStatus("Suche nach neuen Releases...");
var result = await _updateService.CheckForUpdateAsync();
_lastUpdateCheck = result;
_currentVersion.Text = result.CurrentVersion.ToString();
_latestVersion.Text = result.Release.TagName;
_updateAsset.Text = result.InstallAsset?.Name ?? "Kein ZIP-Asset gefunden";
if (!result.IsUpdateAvailable)
{
AppendStatus($"Kein Update verfügbar. Installiert: {result.CurrentVersion}, Release: {result.Release.TagName}.");
MessageBox.Show("Es ist kein Update verfügbar.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
if (result.InstallAsset is null)
{
AppendStatus($"Update {result.Release.TagName} gefunden, aber kein ZIP-Asset zum Installieren.");
MessageBox.Show("Update gefunden, aber das Release enthält kein ZIP-Asset.", Text, MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
_installUpdateButton.Enabled = true;
AppendStatus($"Update {result.Release.TagName} gefunden: {result.InstallAsset.Name}");
MessageBox.Show($"Update {result.Release.TagName} ist verfügbar.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
}
catch (Exception ex)
{
Log.Error(ex, "Update check failed");
AppendStatus($"Update-Prüfung fehlgeschlagen: {ex.Message}");
MessageBox.Show($"Update-Prüfung fehlgeschlagen: {ex.Message}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private async Task InstallUpdateFromUiAsync()
{
if (_lastUpdateCheck is null || _lastUpdateCheck.InstallAsset is null)
{
MessageBox.Show("Bitte zuerst nach Updates suchen.", Text, MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var confirmation = MessageBox.Show(
$"Update {_lastUpdateCheck.Release.TagName} installieren? Die Anwendung wird danach neu gestartet.",
Text,
MessageBoxButtons.YesNo,
MessageBoxIcon.Question);
if (confirmation != DialogResult.Yes)
{
return;
}
try
{
AppendStatus($"Update {_lastUpdateCheck.Release.TagName} wird heruntergeladen...");
await _updateService.InstallUpdateAsync(_lastUpdateCheck);
AppendStatus("Update-Skript gestartet. Anwendung wird beendet.");
Application.Exit();
}
catch (Exception ex)
{
Log.Error(ex, "Update installation failed");
AppendStatus($"Update-Installation fehlgeschlagen: {ex.Message}");
MessageBox.Show($"Update-Installation fehlgeschlagen: {ex.Message}", Text, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private string? GetSelectedPrinterName()
{
return _printer.SelectedItem switch
@@ -227,6 +416,44 @@ internal sealed class SettingsForm : Form
};
}
private List<string> GetSharedPrinterNames()
{
return _sharedPrinters.CheckedItems
.Cast<object>()
.Select(item => item switch
{
PrinterInfo printerInfo => printerInfo.Name,
string printerName => printerName,
_ => null
})
.Where(printerName => !string.IsNullOrWhiteSpace(printerName))
.Cast<string>()
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
private PrinterConfig BuildPrinterConfig(AppSettings settings, string printerName)
{
var existingPrinter = settings.Printers.FirstOrDefault(
printer => string.Equals(printer.WindowsPrinterName, printerName, StringComparison.OrdinalIgnoreCase));
return new PrinterConfig
{
PrinterId = string.IsNullOrWhiteSpace(existingPrinter?.PrinterId)
? SettingsStore.BuildPrinterId(settings.Backend.AgentId, printerName)
: existingPrinter.PrinterId,
Name = string.IsNullOrWhiteSpace(existingPrinter?.Name) ? printerName : existingPrinter.Name,
WindowsPrinterName = printerName,
Dpi = existingPrinter?.Dpi > 0 ? existingPrinter.Dpi : settings.Printer.Dpi,
DefaultWidthMm = Math.Max(1, (int)Math.Round(_labelWidth.Value)),
DefaultHeightMm = Math.Max(1, (int)Math.Round(_labelHeight.Value))
};
}
private static PrinterConfig? GetPrimaryPrinter(AppSettings settings)
{
return settings.Printers.FirstOrDefault(printer => !string.IsNullOrWhiteSpace(printer.WindowsPrinterName));
}
private void AppendStatus(string message)
{
_status.AppendText($"[{DateTime.Now:HH:mm:ss}] {message}{Environment.NewLine}");
@@ -1,6 +1,7 @@
using LabelPrintAgent.Backend;
using LabelPrintAgent.Configuration;
using LabelPrintAgent.Printing;
using LabelPrintAgent.Updates;
using Serilog;
namespace LabelPrintAgent.App;
@@ -10,7 +11,9 @@ internal sealed class TrayApplicationContext : ApplicationContext
private readonly NotifyIcon _notifyIcon;
private readonly SettingsStore _settingsStore;
private readonly PrinterService _printerService;
private readonly BackendClient _backendClient;
private readonly BackendPollingWorker _backendWorker;
private readonly UpdateService _updateService;
private readonly Icon _healthyIcon;
private readonly Icon _unhealthyIcon;
private readonly System.Windows.Forms.Timer _statusTimer;
@@ -19,11 +22,15 @@ internal sealed class TrayApplicationContext : ApplicationContext
public TrayApplicationContext(
SettingsStore settingsStore,
PrinterService printerService,
BackendPollingWorker backendWorker)
BackendClient backendClient,
BackendPollingWorker backendWorker,
UpdateService updateService)
{
_settingsStore = settingsStore;
_printerService = printerService;
_backendClient = backendClient;
_backendWorker = backendWorker;
_updateService = updateService;
_healthyIcon = TrayIconFactory.CreatePrinterIcon(Color.FromArgb(36, 160, 72));
_unhealthyIcon = TrayIconFactory.CreatePrinterIcon(Color.FromArgb(210, 48, 48));
@@ -58,7 +65,7 @@ internal sealed class TrayApplicationContext : ApplicationContext
{
if (_settingsForm is null || _settingsForm.IsDisposed)
{
_settingsForm = new SettingsForm(_settingsStore, _printerService, _backendWorker);
_settingsForm = new SettingsForm(_settingsStore, _printerService, _backendClient, _backendWorker, _updateService);
}
_settingsForm.Show();
@@ -108,12 +115,15 @@ internal sealed class TrayApplicationContext : ApplicationContext
return (false, "Backend-URL fehlt.");
}
if (string.IsNullOrWhiteSpace(settings.Printer.PrinterName))
var configuredPrinters = settings.Printers
.Where(printer => !string.IsNullOrWhiteSpace(printer.WindowsPrinterName))
.ToList();
if (configuredPrinters.Count == 0)
{
return (false, "Drucker fehlt.");
}
if (!_printerService.IsPrinterAvailable(settings.Printer.PrinterName))
if (!configuredPrinters.Any(printer => _printerService.IsPrinterAvailable(printer.WindowsPrinterName)))
{
return (false, "Drucker nicht verfügbar.");
}
+42 -5
View File
@@ -35,6 +35,37 @@ public sealed class BackendClient
return await response.Content.ReadFromJsonAsync<BackendLabelJob>(cancellationToken: cancellationToken);
}
public async Task RegisterPrintersAsync(CancellationToken cancellationToken = default)
{
var settings = _settingsStore.Load();
foreach (var printer in settings.Printers.Where(IsRegisterablePrinter))
{
var body = new
{
printerId = printer.PrinterId,
agentId = settings.Backend.AgentId,
name = printer.Name,
windowsPrinterName = printer.WindowsPrinterName,
dpi = printer.Dpi,
defaultWidthMm = printer.DefaultWidthMm,
defaultHeightMm = printer.DefaultHeightMm
};
using var request = CreateRequest(
HttpMethod.Post,
MakeAbsoluteUrl(settings.Backend.BaseUrl, settings.Backend.RegisterPrinterPath),
settings);
request.Content = JsonContent.Create(body);
using var response = await _httpClient.SendAsync(request, cancellationToken);
await EnsureSuccessWithDiagnosticsAsync(response, request, cancellationToken);
Log.Information(
"Registered printer {PrinterId} ({WindowsPrinterName}) for agent {AgentId}",
printer.PrinterId,
printer.WindowsPrinterName,
settings.Backend.AgentId);
}
}
public async Task<Bitmap> GetLabelImageAsync(BackendLabelJob job, CancellationToken cancellationToken = default)
{
if (!string.IsNullOrWhiteSpace(job.LabelImageBase64))
@@ -46,7 +77,7 @@ public sealed class BackendClient
if (string.IsNullOrWhiteSpace(job.LabelImageUrl))
{
var settingsForPath = _settingsStore.Load();
job.LabelImageUrl = settingsForPath.Backend.ImagePath.Replace("{jobId}", Uri.EscapeDataString(job.JobId), StringComparison.OrdinalIgnoreCase);
job.LabelImageUrl = settingsForPath.Backend.ImagePath.Replace("{jobId}", Uri.EscapeDataString(job.JobId.ToString()), StringComparison.OrdinalIgnoreCase);
}
var settings = _settingsStore.Load();
@@ -57,7 +88,7 @@ public sealed class BackendClient
return CreateBitmap(bytesFromUrl);
}
public Task ReportPrintedAsync(string jobId, string printerName, CancellationToken cancellationToken = default)
public Task ReportPrintedAsync(int jobId, string printerName, CancellationToken cancellationToken = default)
{
var settings = _settingsStore.Load();
var request = new BackendReportRequest
@@ -68,7 +99,7 @@ public sealed class BackendClient
return PostReportAsync(settings, settings.Backend.ReportSuccessPath, jobId, request, cancellationToken);
}
public Task ReportErrorAsync(string jobId, string printerName, string errorMessage, CancellationToken cancellationToken = default)
public Task ReportErrorAsync(int jobId, string printerName, string errorMessage, CancellationToken cancellationToken = default)
{
var settings = _settingsStore.Load();
var request = new BackendReportRequest
@@ -80,9 +111,9 @@ public sealed class BackendClient
return PostReportAsync(settings, settings.Backend.ReportErrorPath, jobId, request, cancellationToken);
}
private async Task PostReportAsync(AppSettings settings, string pathTemplate, string jobId, BackendReportRequest body, CancellationToken cancellationToken)
private async Task PostReportAsync(AppSettings settings, string pathTemplate, int jobId, BackendReportRequest body, CancellationToken cancellationToken)
{
var path = pathTemplate.Replace("{jobId}", Uri.EscapeDataString(jobId), StringComparison.OrdinalIgnoreCase);
var path = pathTemplate.Replace("{jobId}", Uri.EscapeDataString(jobId.ToString()), StringComparison.OrdinalIgnoreCase);
using var request = CreateRequest(HttpMethod.Post, MakeAbsoluteUrl(settings.Backend.BaseUrl, path), settings);
request.Content = JsonContent.Create(body);
using var response = await _httpClient.SendAsync(request, cancellationToken);
@@ -217,6 +248,12 @@ public sealed class BackendClient
return new Bitmap(stream);
}
private static bool IsRegisterablePrinter(PrinterConfig printer)
{
return !string.IsNullOrWhiteSpace(printer.PrinterId)
&& !string.IsNullOrWhiteSpace(printer.WindowsPrinterName);
}
private static bool IsLabelJobAvailableEvent(string data)
{
if (string.IsNullOrWhiteSpace(data))
@@ -2,7 +2,10 @@ namespace LabelPrintAgent.Backend;
public sealed class BackendLabelJob
{
public string JobId { get; set; } = string.Empty;
public int JobId { get; set; }
public string PrinterId { get; set; } = string.Empty;
public string LabelType { get; set; } = string.Empty;
public string WindowsPrinterName { get; set; } = string.Empty;
public string? LabelImageBase64 { get; set; }
public string? LabelImageUrl { get; set; }
public string LabelImageContentType { get; set; } = "image/png";
@@ -54,11 +54,24 @@ public sealed class BackendPollingWorker : IDisposable
return;
}
var printerName = settings.Printer.PrinterName;
if (string.IsNullOrWhiteSpace(printerName) || !_printerService.IsPrinterAvailable(printerName))
var configuredPrinters = settings.Printers
.Where(printer => !string.IsNullOrWhiteSpace(printer.WindowsPrinterName))
.ToList();
if (configuredPrinters.Count == 0)
{
Log.Warning("No available printer configured for backend polling");
SetStatus(false, "Kein verfügbarer Drucker konfiguriert.");
SetStatus(false, "Kein Drucker konfiguriert.");
return;
}
var unavailablePrinters = configuredPrinters
.Where(printer => !_printerService.IsPrinterAvailable(printer.WindowsPrinterName))
.Select(printer => printer.WindowsPrinterName)
.ToList();
if (unavailablePrinters.Count == configuredPrinters.Count)
{
Log.Warning("No configured printer is available for backend polling: {Printers}", string.Join(", ", unavailablePrinters));
SetStatus(false, "Kein konfigurierter Drucker verfügbar.");
return;
}
@@ -76,7 +89,7 @@ public sealed class BackendPollingWorker : IDisposable
}
processedJobs++;
await ProcessJobAsync(job, printerName, cancellationToken);
await ProcessJobAsync(job, cancellationToken);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
@@ -90,10 +103,21 @@ public sealed class BackendPollingWorker : IDisposable
}
}
private async Task ProcessJobAsync(BackendLabelJob job, string printerName, CancellationToken cancellationToken)
private async Task ProcessJobAsync(BackendLabelJob job, CancellationToken cancellationToken)
{
var printerName = job.WindowsPrinterName;
try
{
if (string.IsNullOrWhiteSpace(printerName))
{
throw new InvalidOperationException("Der Druckjob enthält keinen windowsPrinterName.");
}
if (!_printerService.IsPrinterAvailable(printerName))
{
throw new InvalidOperationException($"Drucker '{printerName}' ist nicht verfügbar.");
}
SetStatus(true, $"Job {job.JobId} wird gedruckt.");
using var bitmap = await _backendClient.GetLabelImageAsync(job, cancellationToken);
var result = await _printerService.PrintAsync(bitmap, printerName, job.LabelWidthMm, job.LabelHeightMm, cancellationToken);
@@ -110,7 +134,7 @@ public sealed class BackendPollingWorker : IDisposable
catch (Exception ex)
{
Log.Error(ex, "Could not process backend label job {JobId}", job.JobId);
await _backendClient.ReportErrorAsync(job.JobId, printerName, ex.Message, cancellationToken);
await _backendClient.ReportErrorAsync(job.JobId, printerName ?? string.Empty, ex.Message, cancellationToken);
SetStatus(false, $"Job {job.JobId} fehlgeschlagen: {ex.Message}");
}
}
@@ -3,7 +3,9 @@ namespace LabelPrintAgent.Configuration;
public sealed class AppSettings
{
public BackendSettings Backend { get; set; } = new();
public UpdateSettings Updates { get; set; } = new();
public PrinterSettings Printer { get; set; } = new();
public List<PrinterConfig> Printers { get; set; } = [];
public WorkerSettings Worker { get; set; } = new();
public PathSettings Paths { get; set; } = PathSettings.CreateDefault();
@@ -19,6 +21,7 @@ public sealed class BackendSettings
public string BaseUrl { get; set; } = "https://paperlessmanager.local";
public string AgentId { get; set; } = Environment.MachineName;
public string EncryptedApiToken { get; set; } = string.Empty;
public string RegisterPrinterPath { get; set; } = "/api/label-print-agent/printers/register";
public string NextJobPath { get; set; } = "/api/label-print-agent/jobs/next";
public string ImagePath { get; set; } = "/api/label-print-agent/jobs/{jobId}/image";
public string ReportSuccessPath { get; set; } = "/api/label-print-agent/jobs/{jobId}/printed";
@@ -27,6 +30,13 @@ public sealed class BackendSettings
public string EventsPath { get; set; } = "/api/label-print-agent/events";
}
public sealed class UpdateSettings
{
public string ReleaseApiUrl { get; set; } =
"https://gitea.poettker-cloud.de/api/v1/repos/bjoernpoettker/LabelPrintAgent/releases/latest";
public string EncryptedAccessToken { get; set; } = string.Empty;
}
public sealed class PrinterSettings
{
public string PrinterName { get; set; } = string.Empty;
@@ -35,6 +45,16 @@ public sealed class PrinterSettings
public int Dpi { get; set; } = 300;
}
public sealed class PrinterConfig
{
public string PrinterId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string WindowsPrinterName { get; set; } = string.Empty;
public int Dpi { get; set; } = 203;
public int DefaultWidthMm { get; set; } = 57;
public int DefaultHeightMm { get; set; } = 32;
}
public sealed class WorkerSettings
{
public bool Enabled { get; set; } = true;
@@ -38,6 +38,12 @@ public sealed class SettingsStore
var json = File.ReadAllText(SettingsFilePath);
var settings = JsonSerializer.Deserialize<AppSettings>(json, JsonOptions) ?? new AppSettings();
settings.Paths ??= PathSettings.CreateDefault();
settings.Backend ??= new BackendSettings();
settings.Updates ??= new UpdateSettings();
settings.Printer ??= new PrinterSettings();
settings.Printers ??= [];
settings.Worker ??= new WorkerSettings();
MigrateLegacyPrinter(settings);
return settings;
}
catch (Exception ex)
@@ -58,6 +64,8 @@ public sealed class SettingsStore
public string DecryptBackendApiToken(AppSettings settings) => _protectedStringService.Unprotect(settings.Backend.EncryptedApiToken);
public string DecryptUpdateAccessToken(AppSettings settings) => _protectedStringService.Unprotect(settings.Updates.EncryptedAccessToken);
public static string NormalizeBackendApiToken(string token)
{
if (string.IsNullOrWhiteSpace(token))
@@ -78,4 +86,33 @@ public sealed class SettingsStore
return normalized.Trim().Trim('"', '\'').Trim();
}
private static void MigrateLegacyPrinter(AppSettings settings)
{
if (settings.Printers.Count > 0 || string.IsNullOrWhiteSpace(settings.Printer.PrinterName))
{
return;
}
settings.Printers.Add(new PrinterConfig
{
PrinterId = BuildPrinterId(settings.Backend.AgentId, settings.Printer.PrinterName),
Name = settings.Printer.PrinterName,
WindowsPrinterName = settings.Printer.PrinterName,
Dpi = settings.Printer.Dpi,
DefaultWidthMm = Math.Max(1, (int)Math.Round(settings.Printer.LabelWidthMm)),
DefaultHeightMm = Math.Max(1, (int)Math.Round(settings.Printer.LabelHeightMm))
});
}
public static string BuildPrinterId(string agentId, string windowsPrinterName)
{
var effectiveAgentId = string.IsNullOrWhiteSpace(agentId) ? Environment.MachineName : agentId.Trim();
var printerPart = new string((windowsPrinterName ?? string.Empty)
.Trim()
.Select(character => char.IsLetterOrDigit(character) ? char.ToUpperInvariant(character) : '_')
.ToArray());
printerPart = string.Join("_", printerPart.Split('_', StringSplitOptions.RemoveEmptyEntries));
return $"{effectiveAgentId}_{(string.IsNullOrWhiteSpace(printerPart) ? "PRINTER" : printerPart)}";
}
}
@@ -0,0 +1,3 @@
namespace LabelPrintAgent.Updates;
internal sealed record ReleaseAsset(string Name, string DownloadUrl, long Size);
@@ -0,0 +1,16 @@
namespace LabelPrintAgent.Updates;
internal sealed record ReleaseInfo(
string TagName,
string Name,
string HtmlUrl,
IReadOnlyList<ReleaseAsset> Assets)
{
public ReleaseAsset? FindWindowsZipAsset()
{
return Assets.FirstOrDefault(asset =>
asset.Name.EndsWith("-win-x64.zip", StringComparison.OrdinalIgnoreCase))
?? Assets.FirstOrDefault(asset =>
asset.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase));
}
}
@@ -0,0 +1,9 @@
namespace LabelPrintAgent.Updates;
internal sealed record UpdateCheckResult(
Version CurrentVersion,
ReleaseInfo Release,
bool IsUpdateAvailable)
{
public ReleaseAsset? InstallAsset => Release.FindWindowsZipAsset();
}
@@ -0,0 +1,222 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using LabelPrintAgent.Configuration;
namespace LabelPrintAgent.Updates;
internal sealed class UpdateService
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly SettingsStore _settingsStore;
public UpdateService(SettingsStore settingsStore)
{
_settingsStore = settingsStore;
}
public Version CurrentVersion => GetCurrentVersion();
public async Task<UpdateCheckResult> CheckForUpdateAsync(CancellationToken cancellationToken = default)
{
var settings = _settingsStore.Load();
if (string.IsNullOrWhiteSpace(settings.Updates.ReleaseApiUrl))
{
throw new InvalidOperationException("Die Release-API-URL fehlt.");
}
using var client = CreateHttpClient(settings);
using var response = await client.GetAsync(settings.Updates.ReleaseApiUrl, cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var release = await JsonSerializer.DeserializeAsync<GiteaReleaseResponse>(stream, JsonOptions, cancellationToken)
?? throw new InvalidOperationException("Die Release-Antwort konnte nicht gelesen werden.");
var latest = MapRelease(release);
var latestVersion = ParseVersion(latest.TagName);
var currentVersion = CurrentVersion;
return new UpdateCheckResult(currentVersion, latest, latestVersion > currentVersion);
}
public async Task InstallUpdateAsync(UpdateCheckResult checkResult, CancellationToken cancellationToken = default)
{
var asset = checkResult.InstallAsset
?? throw new InvalidOperationException("Das Release enthält kein installierbares ZIP-Asset.");
var installFolder = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var executablePath = Application.ExecutablePath;
var updateRoot = Path.Combine(Path.GetTempPath(), "LabelPrintAgent", "updates", checkResult.Release.TagName);
var downloadFile = Path.Combine(updateRoot, asset.Name);
var extractFolder = Path.Combine(updateRoot, "extracted");
var updaterScript = Path.Combine(updateRoot, "apply-update.cmd");
Directory.CreateDirectory(updateRoot);
if (Directory.Exists(extractFolder))
{
Directory.Delete(extractFolder, recursive: true);
}
Directory.CreateDirectory(extractFolder);
var settings = _settingsStore.Load();
using var client = CreateHttpClient(settings);
await using (var source = await client.GetStreamAsync(asset.DownloadUrl, cancellationToken))
await using (var target = File.Create(downloadFile))
{
await source.CopyToAsync(target, cancellationToken);
}
ZipFile.ExtractToDirectory(downloadFile, extractFolder, overwriteFiles: true);
WriteUpdaterScript(updaterScript, extractFolder, installFolder, executablePath, Environment.ProcessId);
StartUpdater(updaterScript);
}
private HttpClient CreateHttpClient(AppSettings settings)
{
var client = new HttpClient();
client.DefaultRequestHeaders.UserAgent.ParseAdd("LabelPrintAgent");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var token = _settingsStore.DecryptUpdateAccessToken(settings).Trim();
if (!string.IsNullOrWhiteSpace(token))
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("token", token);
}
return client;
}
private static ReleaseInfo MapRelease(GiteaReleaseResponse release)
{
var assets = release.Assets
.Where(asset => !string.IsNullOrWhiteSpace(asset.Name) && !string.IsNullOrWhiteSpace(asset.BrowserDownloadUrl))
.Select(asset => new ReleaseAsset(asset.Name, asset.BrowserDownloadUrl, asset.Size))
.ToList();
if (string.IsNullOrWhiteSpace(release.TagName))
{
throw new InvalidOperationException("Das Release enthält keinen Tag.");
}
return new ReleaseInfo(
release.TagName,
string.IsNullOrWhiteSpace(release.Name) ? release.TagName : release.Name,
release.HtmlUrl,
assets);
}
private static Version GetCurrentVersion()
{
var assembly = Assembly.GetExecutingAssembly();
var informational = assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion;
if (!string.IsNullOrWhiteSpace(informational))
{
return ParseVersion(informational);
}
return assembly.GetName().Version ?? new Version(0, 0, 0);
}
private static Version ParseVersion(string value)
{
var cleaned = value.Trim();
if (cleaned.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
cleaned = cleaned[1..];
}
var metadataIndex = cleaned.IndexOf('+', StringComparison.Ordinal);
if (metadataIndex >= 0)
{
cleaned = cleaned[..metadataIndex];
}
var prereleaseIndex = cleaned.IndexOf('-', StringComparison.Ordinal);
if (prereleaseIndex >= 0)
{
cleaned = cleaned[..prereleaseIndex];
}
return Version.TryParse(cleaned, out var version)
? version
: new Version(0, 0, 0);
}
private static void WriteUpdaterScript(
string scriptPath,
string sourceFolder,
string targetFolder,
string executablePath,
int processId)
{
var script = $"""
@echo off
setlocal
set "SOURCE={sourceFolder}"
set "TARGET={targetFolder}"
set "EXE={executablePath}"
set "PID={processId}"
:wait
tasklist /FI "PID eq %PID%" 2>NUL | find "%PID%" >NUL
if not errorlevel 1 (
timeout /t 1 /nobreak >NUL
goto wait
)
xcopy "%SOURCE%\*" "%TARGET%\" /E /I /Y >NUL
start "" "%EXE%"
del "%~f0"
""";
File.WriteAllText(scriptPath, script);
}
private static void StartUpdater(string scriptPath)
{
Process.Start(new ProcessStartInfo
{
FileName = scriptPath,
UseShellExecute = true,
WindowStyle = ProcessWindowStyle.Hidden
});
}
private sealed class GiteaReleaseResponse
{
[JsonPropertyName("tag_name")]
public string TagName { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("html_url")]
public string HtmlUrl { get; set; } = string.Empty;
[JsonPropertyName("assets")]
public List<GiteaReleaseAssetResponse> Assets { get; set; } = [];
}
private sealed class GiteaReleaseAssetResponse
{
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("browser_download_url")]
public string BrowserDownloadUrl { get; set; } = string.Empty;
[JsonPropertyName("size")]
public long Size { get; set; }
}
}