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
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:
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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))));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user