feat(print): Windows print agent — the cloud↔LAN bridge (Phase 3)
CI/CD / CI · API (dotnet build + test) (push) Successful in 39s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 33s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 29s

A standalone net10.0-windows console app (agent/Meezi.PrintAgent) installed on the
café cash PC. It pairs with a one-time code (POST /print-agent/claim), stores the
token in %APPDATA%, holds a SignalR connection to /hubs/print-agent (retries
forever, re-reports on reconnect), discovers installed printers via WMI (USB +
network, classified), and prints jobs it receives by writing raw ESC/POS bytes —
winspool RAW passthrough for installed printers, raw TCP for ip:port devices —
acking each job back. Not in the API solution or CI (own net10.0-windows build);
see agent/README.md for build/publish/pair. Builds clean; startup + pairing flow
smoke-tested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-25 12:16:28 +03:30
parent 9e47a4e60c
commit 7d5af0c81b
7 changed files with 432 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
using System.Text.Json;
namespace Meezi.PrintAgent;
/// <summary>Persisted agent identity — written to %APPDATA%\MeeziPrintAgent\config.json.</summary>
public class AgentConfig
{
/// <summary>Origin of the Meezi API, e.g. https://app.meezi.ir.</summary>
public string? ApiBaseUrl { get; set; }
public string? Token { get; set; }
public string? CafeId { get; set; }
public string? AgentId { get; set; }
public string? Name { get; set; }
private static string Dir =>
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MeeziPrintAgent");
private static string FilePath => Path.Combine(Dir, "config.json");
public bool IsPaired => !string.IsNullOrWhiteSpace(Token) && !string.IsNullOrWhiteSpace(ApiBaseUrl);
public static AgentConfig Load()
{
try
{
if (File.Exists(FilePath))
return JsonSerializer.Deserialize<AgentConfig>(File.ReadAllText(FilePath)) ?? new AgentConfig();
}
catch
{
// corrupt/unreadable config → start fresh
}
return new AgentConfig();
}
public void Save()
{
Directory.CreateDirectory(Dir);
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<!-- Windows-only: uses winspool (raw printing) + WMI (printer discovery).
Overrides the repo-wide net10.0 / central package management on purpose so
this app stays independent of the API build (CI never compiles it). -->
<TargetFramework>net10.0-windows</TargetFramework>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>Meezi.PrintAgent</RootNamespace>
<AssemblyName>MeeziPrintAgent</AssemblyName>
<Version>0.1.0</Version>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />
<PackageReference Include="System.Management" Version="10.0.0" />
</ItemGroup>
</Project>
+43
View File
@@ -0,0 +1,43 @@
using System.Net.Http.Json;
namespace Meezi.PrintAgent;
/// <summary>Redeems a one-time pairing code for a long-lived agent token.</summary>
public static class Pairing
{
private record ClaimReq(string code, string? name, string? machineName);
private record ApiEnvelope<T>(bool success, T? data);
private record ClaimData(string agentId, string token, string cafeId, string agentName);
public static async Task<AgentConfig?> ClaimAsync(string apiBaseUrl, string code, string name)
{
using var http = new HttpClient { BaseAddress = new Uri(apiBaseUrl), Timeout = TimeSpan.FromSeconds(20) };
HttpResponseMessage resp;
try
{
resp = await http.PostAsJsonAsync("/api/print-agent/claim",
new ClaimReq(code, name, Environment.MachineName));
}
catch (Exception ex)
{
Console.WriteLine($" network error: {ex.Message}");
return null;
}
if (!resp.IsSuccessStatusCode)
return null;
var env = await resp.Content.ReadFromJsonAsync<ApiEnvelope<ClaimData>>();
if (env?.success != true || env.data is null)
return null;
return new AgentConfig
{
ApiBaseUrl = apiBaseUrl.TrimEnd('/'),
Token = env.data.token,
CafeId = env.data.cafeId,
AgentId = env.data.agentId,
Name = env.data.agentName,
};
}
}
@@ -0,0 +1,45 @@
using System.Management;
using System.Runtime.Versioning;
namespace Meezi.PrintAgent;
/// <summary>One printer the agent can reach. SystemName is what it prints to (the
/// Windows printer name, or "ip:port" for a raw network device).</summary>
public record DiscoveredPrinter(string SystemName, string DisplayName, string Kind);
[SupportedOSPlatform("windows")]
public static class PrinterDiscovery
{
/// <summary>Every printer installed on this PC (USB and network-with-driver alike).</summary>
public static List<DiscoveredPrinter> Discover()
{
var list = new List<DiscoveredPrinter>();
try
{
using var searcher = new ManagementObjectSearcher(
"SELECT Name, PortName, Network FROM Win32_Printer");
foreach (var o in searcher.Get())
{
using var p = (ManagementObject)o;
var name = p["Name"]?.ToString();
if (string.IsNullOrWhiteSpace(name)) continue;
var port = p["PortName"]?.ToString() ?? "";
var network = p["Network"] as bool? ?? false;
list.Add(new DiscoveredPrinter(name!, name!, ClassifyKind(port, network)));
}
}
catch
{
// WMI unavailable — report nothing rather than crash.
}
return list;
}
private static string ClassifyKind(string port, bool network)
{
var up = port.ToUpperInvariant();
if (up.StartsWith("USB") || up.StartsWith("DOT4")) return "usb";
if (network || up.StartsWith("IP_") || up.StartsWith("WSD") || up.Contains(':')) return "network";
return "other";
}
}
+143
View File
@@ -0,0 +1,143 @@
using Meezi.PrintAgent;
using Microsoft.AspNetCore.SignalR.Client;
Console.OutputEncoding = System.Text.Encoding.UTF8;
Console.WriteLine("=== Meezi Print Agent (پرینت‌سرور میزی) ===");
var config = AgentConfig.Load();
var wantsPair = args.Length > 0 && args[0].Equals("pair", StringComparison.OrdinalIgnoreCase);
if (!config.IsPaired || wantsPair)
{
var paired = await PairInteractiveAsync(config);
if (paired is null)
{
Console.WriteLine("Pairing cancelled or failed.");
return 1;
}
paired.Save();
config = paired;
Console.WriteLine($"✓ Paired as '{config.Name}'. Configuration saved.");
}
await RunAsync(config);
return 0;
static async Task<AgentConfig?> PairInteractiveAsync(AgentConfig existing)
{
var defaultUrl = existing.ApiBaseUrl ?? "https://app.meezi.ir";
Console.Write($"Meezi API URL [{defaultUrl}]: ");
var url = Console.ReadLine();
if (string.IsNullOrWhiteSpace(url)) url = defaultUrl;
Console.Write("Pairing code (from Dashboard → Settings → Printers): ");
var code = Console.ReadLine()?.Trim();
if (string.IsNullOrWhiteSpace(code)) return null;
Console.Write($"Name for this PC [{Environment.MachineName}]: ");
var name = Console.ReadLine();
if (string.IsNullOrWhiteSpace(name)) name = Environment.MachineName;
Console.WriteLine("Pairing…");
var cfg = await Pairing.ClaimAsync(url!, code!, name!);
if (cfg is null) Console.WriteLine(" Invalid/expired code, or the URL is wrong.");
return cfg;
}
static async Task RunAsync(AgentConfig config)
{
var hubUrl = $"{config.ApiBaseUrl!.TrimEnd('/')}/hubs/print-agent" +
$"?access_token={Uri.EscapeDataString(config.Token!)}";
var connection = new HubConnectionBuilder()
.WithUrl(hubUrl)
.WithAutomaticReconnect(new ForeverRetry())
.Build();
connection.On<string, string, string>("PrintJob", async (jobId, printerSystemName, base64) =>
{
var ok = false;
string? err = null;
try
{
var data = Convert.FromBase64String(base64);
await RawPrinter.PrintAsync(printerSystemName, data, CancellationToken.None);
ok = true;
Console.WriteLine($"[print] {data.Length} bytes → {printerSystemName} ✓");
}
catch (Exception ex)
{
err = ex.Message;
Console.WriteLine($"[print] {printerSystemName} ✗ {ex.Message}");
}
try { await connection.InvokeAsync("JobResult", jobId, ok, err); } catch { /* ack best-effort */ }
});
connection.Reconnected += async _ =>
{
Console.WriteLine("[hub] reconnected");
await SafeReportAsync(connection);
};
connection.Closed += _ =>
{
Console.WriteLine("[hub] connection closed");
return Task.CompletedTask;
};
await ConnectWithRetryAsync(connection);
Console.WriteLine("[hub] connected");
await SafeReportAsync(connection);
// Heartbeat + re-report every 2 minutes (printers added/removed get picked up).
_ = Task.Run(async () =>
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(2));
while (await timer.WaitForNextTickAsync())
{
try
{
await connection.InvokeAsync("Heartbeat");
await SafeReportAsync(connection);
}
catch { /* will recover on reconnect */ }
}
});
Console.WriteLine("Agent running. Leave this window open. Press Ctrl+C to quit.");
await Task.Delay(Timeout.Infinite);
}
static async Task SafeReportAsync(HubConnection connection)
{
try
{
var printers = PrinterDiscovery.Discover();
await connection.InvokeAsync("ReportPrinters", printers);
Console.WriteLine($"[printers] reported {printers.Count}: " +
string.Join(", ", printers.Select(p => $"{p.DisplayName} ({p.Kind})")));
}
catch (Exception ex)
{
Console.WriteLine($"[printers] report failed: {ex.Message}");
}
}
static async Task ConnectWithRetryAsync(HubConnection connection)
{
while (true)
{
try { await connection.StartAsync(); return; }
catch (Exception ex)
{
Console.WriteLine($"[hub] connect failed: {ex.Message}; retrying in 5s");
await Task.Delay(5000);
}
}
}
/// <summary>Retry reconnecting forever with capped exponential backoff.</summary>
sealed class ForeverRetry : IRetryPolicy
{
public TimeSpan? NextRetryDelay(RetryContext ctx) =>
TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, Math.Min(ctx.PreviousRetryCount, 5))));
}
+95
View File
@@ -0,0 +1,95 @@
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace Meezi.PrintAgent;
/// <summary>Writes raw ESC/POS bytes to a printer — by Windows name (winspool RAW
/// passthrough) or to an "ip:port" endpoint (raw TCP).</summary>
[SupportedOSPlatform("windows")]
public static class RawPrinter
{
public static async Task PrintAsync(string systemName, byte[] data, CancellationToken ct)
{
if (TryParseEndpoint(systemName, out var ip, out var port))
{
await PrintTcpAsync(ip, port, data, ct);
return;
}
if (!SendBytesToPrinter(systemName, data))
throw new Exception($"winspool write failed (last error {Marshal.GetLastWin32Error()})");
}
private static bool TryParseEndpoint(string s, out string ip, out int port)
{
ip = "";
port = 9100;
var idx = s.LastIndexOf(':');
if (idx <= 0) return false;
var host = s[..idx];
if (!host.Contains('.')) return false; // not an IPv4-ish host → treat as printer name
if (int.TryParse(s[(idx + 1)..], out var p)) port = p;
ip = host;
return true;
}
private static async Task PrintTcpAsync(string ip, int port, byte[] data, CancellationToken ct)
{
using var client = new TcpClient();
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(8));
await client.ConnectAsync(ip, port, cts.Token);
await using var stream = client.GetStream();
await stream.WriteAsync(data, cts.Token);
await stream.FlushAsync(cts.Token);
}
// ── winspool raw printing ────────────────────────────────────────────────
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct DOCINFOW
{
[MarshalAs(UnmanagedType.LPWStr)] public string pDocName;
[MarshalAs(UnmanagedType.LPWStr)] public string? pOutputFile;
[MarshalAs(UnmanagedType.LPWStr)] public string pDataType;
}
[DllImport("winspool.drv", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool OpenPrinter(string src, out IntPtr hPrinter, IntPtr pd);
[DllImport("winspool.drv", SetLastError = true)]
private static extern bool ClosePrinter(IntPtr hPrinter);
[DllImport("winspool.drv", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool StartDocPrinter(IntPtr hPrinter, int level, ref DOCINFOW di);
[DllImport("winspool.drv", SetLastError = true)]
private static extern bool EndDocPrinter(IntPtr hPrinter);
[DllImport("winspool.drv", SetLastError = true)]
private static extern bool StartPagePrinter(IntPtr hPrinter);
[DllImport("winspool.drv", SetLastError = true)]
private static extern bool EndPagePrinter(IntPtr hPrinter);
[DllImport("winspool.drv", SetLastError = true)]
private static extern bool WritePrinter(IntPtr hPrinter, IntPtr pBytes, int dwCount, out int dwWritten);
private static bool SendBytesToPrinter(string printerName, byte[] bytes)
{
if (!OpenPrinter(printerName, out var hPrinter, IntPtr.Zero)) return false;
try
{
var di = new DOCINFOW { pDocName = "Meezi Receipt", pDataType = "RAW" };
if (!StartDocPrinter(hPrinter, 1, ref di)) return false;
try
{
if (!StartPagePrinter(hPrinter)) return false;
var ptr = Marshal.AllocHGlobal(bytes.Length);
try
{
Marshal.Copy(bytes, 0, ptr, bytes.Length);
if (!WritePrinter(hPrinter, ptr, bytes.Length, out _)) return false;
}
finally { Marshal.FreeHGlobal(ptr); }
EndPagePrinter(hPrinter);
}
finally { EndDocPrinter(hPrinter); }
}
finally { ClosePrinter(hPrinter); }
return true;
}
}
+43
View File
@@ -0,0 +1,43 @@
# Meezi Print Agent (پرینت‌سرور میزی)
A tiny Windows background app that lets the **cloud-hosted** Meezi reach printers on
the café's **local network** (USB or Wi-Fi/Ethernet). The cloud can't open a
connection to a `192.168.x.x` or USB printer directly — this agent runs on the cash
PC (which *is* on that network), connects **outward** to Meezi over SignalR, reports
the printers it can see, and prints the jobs the cloud sends it.
```
Cloud API ──SignalR(out)──► Print Agent (cash PC) ──► USB / LAN printers
```
## How it works
1. In the dashboard: **Settings → Printers → Add print server** → you get a pairing code.
2. Run the agent on the cash PC, enter the code once. It saves a token to
`%APPDATA%\MeeziPrintAgent\config.json` and connects.
3. It reports every printer installed on that PC. Back in the dashboard you map
*receipt / kitchen / bar* to a printer from the dropdown — no IP typing.
4. When Meezi prints, the bytes (ESC/POS) are relayed to the agent, which writes them
raw to the chosen printer (`winspool` for installed printers, raw TCP for
`ip:port` devices).
## Build & run (dev)
Requires the .NET 10 SDK on Windows.
```sh
# restore via the Nexus mirror (nuget.org is blocked on this network)
dotnet restore agent/Meezi.PrintAgent/Meezi.PrintAgent.csproj -s https://mirror.soroushasadi.com/repository/nuget-group/
dotnet run --project agent/Meezi.PrintAgent # first run prompts to pair
dotnet run --project agent/Meezi.PrintAgent -- pair # re-pair later
```
## Publish a single .exe for cafés
```sh
dotnet publish agent/Meezi.PrintAgent -c Release -r win-x64 \
-p:PublishSingleFile=true --self-contained true -o dist/agent
# → dist/agent/MeeziPrintAgent.exe
```
## Notes / roadmap
- **Not part of the API solution or CI** — it targets `net10.0-windows` and builds on its own.
- Console MVP today. Next: system-tray UI, run-at-login (Task Scheduler / service), auto-update, and an optional LAN scan for raw `ip:9100` printers that aren't installed in Windows.
- The token is bearer-equivalent — keep `config.json` on a trusted machine. Revoke from the dashboard if a PC is lost.