diff --git a/agent/Meezi.PrintAgent/AgentConfig.cs b/agent/Meezi.PrintAgent/AgentConfig.cs new file mode 100644 index 0000000..c44bfb9 --- /dev/null +++ b/agent/Meezi.PrintAgent/AgentConfig.cs @@ -0,0 +1,40 @@ +using System.Text.Json; + +namespace Meezi.PrintAgent; + +/// Persisted agent identity — written to %APPDATA%\MeeziPrintAgent\config.json. +public class AgentConfig +{ + /// Origin of the Meezi API, e.g. https://app.meezi.ir. + 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(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 })); + } +} diff --git a/agent/Meezi.PrintAgent/Meezi.PrintAgent.csproj b/agent/Meezi.PrintAgent/Meezi.PrintAgent.csproj new file mode 100644 index 0000000..610f970 --- /dev/null +++ b/agent/Meezi.PrintAgent/Meezi.PrintAgent.csproj @@ -0,0 +1,23 @@ + + + + Exe + + net10.0-windows + false + enable + enable + Meezi.PrintAgent + MeeziPrintAgent + 0.1.0 + true + + + + + + + + diff --git a/agent/Meezi.PrintAgent/Pairing.cs b/agent/Meezi.PrintAgent/Pairing.cs new file mode 100644 index 0000000..73cb85f --- /dev/null +++ b/agent/Meezi.PrintAgent/Pairing.cs @@ -0,0 +1,43 @@ +using System.Net.Http.Json; + +namespace Meezi.PrintAgent; + +/// Redeems a one-time pairing code for a long-lived agent token. +public static class Pairing +{ + private record ClaimReq(string code, string? name, string? machineName); + private record ApiEnvelope(bool success, T? data); + private record ClaimData(string agentId, string token, string cafeId, string agentName); + + public static async Task 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>(); + 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, + }; + } +} diff --git a/agent/Meezi.PrintAgent/PrinterDiscovery.cs b/agent/Meezi.PrintAgent/PrinterDiscovery.cs new file mode 100644 index 0000000..1be87b1 --- /dev/null +++ b/agent/Meezi.PrintAgent/PrinterDiscovery.cs @@ -0,0 +1,45 @@ +using System.Management; +using System.Runtime.Versioning; + +namespace Meezi.PrintAgent; + +/// One printer the agent can reach. SystemName is what it prints to (the +/// Windows printer name, or "ip:port" for a raw network device). +public record DiscoveredPrinter(string SystemName, string DisplayName, string Kind); + +[SupportedOSPlatform("windows")] +public static class PrinterDiscovery +{ + /// Every printer installed on this PC (USB and network-with-driver alike). + public static List Discover() + { + var list = new List(); + 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"; + } +} diff --git a/agent/Meezi.PrintAgent/Program.cs b/agent/Meezi.PrintAgent/Program.cs new file mode 100644 index 0000000..7dfa2a1 --- /dev/null +++ b/agent/Meezi.PrintAgent/Program.cs @@ -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 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("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); + } + } +} + +/// Retry reconnecting forever with capped exponential backoff. +sealed class ForeverRetry : IRetryPolicy +{ + public TimeSpan? NextRetryDelay(RetryContext ctx) => + TimeSpan.FromSeconds(Math.Min(30, Math.Pow(2, Math.Min(ctx.PreviousRetryCount, 5)))); +} diff --git a/agent/Meezi.PrintAgent/RawPrinter.cs b/agent/Meezi.PrintAgent/RawPrinter.cs new file mode 100644 index 0000000..3e44b99 --- /dev/null +++ b/agent/Meezi.PrintAgent/RawPrinter.cs @@ -0,0 +1,95 @@ +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace Meezi.PrintAgent; + +/// Writes raw ESC/POS bytes to a printer — by Windows name (winspool RAW +/// passthrough) or to an "ip:port" endpoint (raw TCP). +[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; + } +} diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..d7de083 --- /dev/null +++ b/agent/README.md @@ -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.