Cross-platform port: WPF → Avalonia 12 + .NET 10 (Windows/macOS/Linux)#3
Open
danielmeza wants to merge 43 commits into
Open
Cross-platform port: WPF → Avalonia 12 + .NET 10 (Windows/macOS/Linux)#3danielmeza wants to merge 43 commits into
danielmeza wants to merge 43 commits into
Conversation
…port, CI/CD) (#1) Squash-merge of the Avalonia/.NET 10 migration and feature work.
Unsigned bundles are reported as "damaged and can't be opened" by Gatekeeper on Apple Silicon when downloaded. make-macos-app.sh now ad-hoc signs the assembled bundle (codesign --force --deep --sign -) as the final step, turning that into the normal quarantine prompt. Not notarized — README documents clearing the quarantine flag (xattr -dr com.apple.quarantine) on first launch. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… app dir (#3) The Test print and Hex Dump buttons read test_receipt.txt with a bare relative path. When the app is launched from Finder/Explorer the process working directory is "/" (not the app folder), so File.Exists fails and the buttons silently do nothing — "no receipt". Resolve the path against AppContext.BaseDirectory (the executable's directory, where the file is copied) so it works regardless of CWD. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…reen toast)
Previously Beep() only wrote a terminal bell (inaudible from a GUI app) and the
flash was Windows-only, so on macOS/Linux the buzzer and cash-drawer events
produced nothing visible or audible.
- NotificationService now plays a best-effort system sound: afplay (macOS),
Console.Beep on a background thread (Windows), paplay/aplay/canberra (Linux),
terminal-bell fallback.
- The view model shows a transient on-screen toast ("🔔 Buzzer" /
"💵 Cash drawer opened") that auto-hides — always-visible feedback since the
machine may be muted. New ToastMessage/ToastVisible bound to an overlay in
MainWindow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tate panel Foundation for status reporting and simulation: - PrinterState (observable): online, cover, paper level, drawer sensor, error, feed button. - IPrinterResponder + duplex transports: NetClient/SerialServer register as responders and can write bytes back to the host; FeedEscPos carries the active responder. - Real-time DLE path in the interpreter: DLE EOT n (status 1-4), DLE ENQ (recover), DLE DC4 1 (drawer pulse). StatusByteBuilder computes ESC/POS status bytes from state. - Commands: GS r (paper/drawer status), GS I (printer ID), GS a (Automatic Status Back, pushes a 4-byte block on every state change). - Right-side 'Printer state' panel bound two-way to PrinterState (online/cover/paper/ error/drawer/feed). Off-thread state writes (drawer kick over TCP) marshal to the UI thread via printer.UiDispatch. Verified over TCP: DLE EOT/GS r return correct bytes; ESC p flips drawer status (0x12->0x16, GS r 2 0->1).
…D symbols - ESC ( A beeper -> Buzz(); ParenCommand base for length-prefixed ESC(/GS( commands. - Config no-ops parsed-and-ignored: GS ( E (user setup), GS ( K (print control), GS ( H (response request), GS P (motion units). - Generalize GS ( k to all 2D families: PDF417 (cn=48), QR (cn=49, QRCoder), DataMatrix (cn=54), Aztec (cn=55) via ZXing BarcodeRenderer.Render2D. Verified: PDF417/DataMatrix/Aztec render and round-trip decode to their content.
- ESC * m nL nH ...: classic inline bit-image band (8-dot m=0/1, 24-dot m=32/33), column-major vertical bytes rendered to SKBitmap. - GS * x y ...: define downloaded bit image (x*8 by y*8); GS / m prints it with scaling (0 normal, 1 dw, 2 dh, 3 dwh). Verified ESC * renders a recognizable image.
- ESC L buffers output into an off-stack receipt; FF rasterizes it onto the receipt and returns to standard mode; ESC S / CAN discard the buffer. - ESC W (print area), ESC T (direction), ESC $ / GS $ / GS \ (positioning) are parsed and accepted (approximated — content is buffered then rasterized). - FixedArgNoOpCommand consumes fixed-length args for positioning commands. Verified: text buffered in page mode prints on FF; standard text resumes after.
- ESC t selects the character code table; high bytes are remapped through the active code page (PC437/850/852/858/860/863/865/866/1252, Katakana) to Unicode before rendering. Registers CodePagesEncodingProvider for cross-platform support. - ESC & parses and stores user-defined glyph bitmaps (column-major); ESC % toggles the set; ESC ? cancels a glyph. (Inline glyph substitution during text rendering is not applied — the font glyph is drawn; these rarely appear in modern streams.) Verified: CP1252 maps 0xE9/0xEF -> é/ï; ESC & stores a 4x24 glyph; text after the sequence prints without corruption.
…image Regenerate test_receipt.txt via scripts/gen-test-receipt.py to exercise every supported feature: text styles (bold/italic/underline/font B/double sizes), alignment, all 1D barcodes (UPC-A, EAN-13/8, CODE39/93/128, ITF, CODABAR), all 2D symbols (QR, PDF417, DataMatrix, Aztec), an ESC * bit image, and the buzzer + cash drawer. Verified all barcodes/2D render without errors.
- New Monitor window (separate) using ESC-POS-.NET (NetworkPrinter + EPSON emitter) connects to the emulator over TCP and can: print a sample receipt, all 1D barcodes, QR/PDF417/DataMatrix/Aztec, the full feature test, open the drawer, buzz, and cut. - Subscribes to StatusChanged and enables Automatic Status Back, so toggling the Printer state panel updates the monitor's status display live. - Fix StatusByteBuilder.AutoStatusBack to match the ESC-POS-.NET 4-byte ASB parser (byte0 bit4 fixed, inverted drawer bit, paired paper-low/out bits). Verified end-to-end: paper-low/out, cover, drawer and offline all round-trip to PrinterStatusEventArgs. - 'Open monitor' button in the main window (single instance).
- Like a real printer, the emulator now refuses to print when not ready (out of paper, cover open, offline, or error): print/barcode/2D/bit-image ops are dropped and an OnPrintBlocked notification fires (shown as a toast). Notifies once per blocked episode and re-arms when the printer becomes ready again. - Monitor: replace the plain text status with colored indicator rows (dot + label + value) for Printer/Paper/Cover/Cash drawer/Error, plus a Ready/Not ready summary. Verified: printing blocked on paper-out/cover-open/offline and resumes when restored (single notification per episode).
LineFeed was not gated, so while printing was blocked the paper-feed commands still added empty lines (and cuts started receipts), producing blank receipt cards. Gate LineFeed on the ready state too; a not-ready printer now produces no output at all. Verified: sending the full test receipt with the cover open yields 0 receipts; ready yields output.
…fies The 'blocked' latch only reset when the printer returned to ready, so repeated print attempts while still not-ready (e.g. clicking Test print again with the cover open) were silently suppressed and showed no toast. Reset the latch at the start of each FeedEscPos so every received job that is blocked notifies once (still deduped within a single job). Verified: 3 separate blocked jobs -> 3 notifications; one multi-print job -> 1.
…gic constants
- Enable TreatWarningsAsErrors and NuGetAuditMode=all: build/CI now fails on any compiler,
analyzer, or NuGet security-audit warning (verified: a vulnerable transitive package
produces 'error NU1903: Warning As Error').
- Fix existing warnings:
- CA1416: repeat the OperatingSystem.IsWindows() guard inside the Task.Run lambda so
the Console.Beep call site is platform-checked; drop the annotated helper.
- NU1902/NU1903: override ESCPOS_NET's vulnerable transitive SixLabors.ImageSharp 2.1.3
with patched 2.1.13.
- NU1510: drop System.Text.Encoding.CodePages (provided by the framework on .NET 10;
code-page remapping still works).
- Replace magic numbers with named constants: DLE real-time sub-commands (EOT/ENQ/DC4/pulse)
and lengths, GS ( k function codes, a shared TwoDimensionCode (cn) type used by the printer,
the GS ( k command and the monitor, and BEL/module-size in the monitor.
Build is clean (0 warnings) in Debug and Release.
…e Monitor - Notifications (buzzer / cash drawer / print-blocked for text, barcode, QR, etc.) were cut short when several fired close together: each ShowToast scheduled its own one-shot timer and an earlier timer would hide a later toast. Use a single re-armable DispatcherTimer restarted on each toast, and lengthen the duration to 3.5s. - Remove the emulator's 'Test print' button/command: the emulator is the device and the Monitor window is the client that drives it over the wire (Send full feature test, all barcodes, QR/PDF417/DataMatrix/Aztec, drawer, buzz, cut). README updated to reflect this.
Add the monitor client screenshot and a side-by-side showing live status sync (the emulator's Printer state panel pushing paper-low / recoverable-error to the monitor, which then reports Not ready) to the Monitor section.
The Monitor window now offers a transport toggle (TCP/IP or Serial). Serial uses ESC-POS-.NET's SerialPrinter (port picker + baud, with a refresh button) sharing the same BasePrinter Write/StatusChanged path as the TCP NetworkPrinter, so sending jobs and the live status round-trip work over either transport. Pairs with the emulator's serial transport via a virtual port bridge (socat/com0com). Verified status parsing over serial.
The monitor's transport selector now offers TCP / Serial / USB. USB enumerates connected devices (VID:PID) via LibUsbDotNet/libusb, opens the selected one, claims its first interface and writes ESC/POS to the bulk-OUT endpoint — for printing directly to a real USB receipt printer that isn't exposed as a COM port. Send-only (no status read-back). - New UsbBulkTransport (enumerate / open by VID:PID / write); lazily loads libusb so the app still runs without it, and surfaces a clear error if libusb is missing or the OS already owns the device. - Monitor VM holds either an ESC-POS-.NET BasePrinter (TCP/serial) or the USB transport; Send writes to whichever is active. - README documents the USB transport and its native libusb requirement. Note: USB depends on native libusb and a real device, so it could not be exercised in CI.
LibUsbDotNet P/Invokes 'libusb-1.0', but .NET's default loader doesn't search Homebrew (/opt/homebrew/lib) or manual install paths, so an installed libusb still fails to load. Register a DllImportResolver on the LibUsbDotNet assembly that locates libusb across common macOS/Linux/Windows locations (and a copy shipped next to the executable), falling back to default resolution. Registered lazily in UsbBulkTransport's static ctor. Users still need libusb present (macOS: brew install libusb); the resolver makes an installed copy discoverable without extra DYLD/LD path configuration.
…vity log - When libusb can't be loaded (refresh or connect over USB), log a clear, multi-line message explaining USB is unavailable and how to install libusb for the current OS (brew/apt), instead of a raw exception. - Activity log entries are now selectable text, plus a 'Copy' button that copies the whole log (chronological) to the clipboard via Avalonia 12's clipboard API.
…f a resolver The previous DllImportResolver was registered on the LibUsbDotNet assembly — but LibUsbDotNet registers its OWN resolver on that same assembly in a static initializer, and the duplicate registration threw during its type init (surfacing as 'Unable to load libusb-1.0' even when libusb was installed). LibUsbDotNet's resolver searches NATIVE_DLL_SEARCH_DIRECTORIES (then the default OS path), which omits Homebrew/manual locations. So instead of a resolver, append the common libusb dirs (/opt/homebrew/lib, /opt/homebrew/opt/libusb/lib, /usr/local/lib, Linux lib dirs) to that AppContext list in UsbBulkTransport's static ctor, before any libusb call. No symlinks, no DYLD_LIBRARY_PATH. Verified: with libusb installed via brew, the app now enumerates USB devices (incl. 0471:0055).
…inter) Replace the send-only UsbBulkTransport with a UsbPrinter that derives from ESC-POS-.NET's BasePrinter, backed by a USB-endpoint Stream. Writes go to the bulk-OUT endpoint and status is read from the bulk-IN endpoint, so USB now joins the same write-queue + Automatic-Status-Back pipeline as the serial/TCP printers and the monitor reflects the printer's reported state over USB too. - Fix "SafeHandle cannot be null" on send: the device collection was disposed in Open(), invalidating the device handle before the write. Keep the collection + context alive for the connection's lifetime (owned by the stream, freed on Dispose). - UsbStream buffers bulk-IN reads (USB is packet-oriented; BasePrinter reads the status channel one byte at a time) and returns 0 on timeout so the read loop stays responsive without spinning. - Auto-detect bulk OUT/IN endpoints from the interface descriptors (direction via bEndpointAddress bit 7, type via bmAttributes); fall back to Ep01 for output, and treat a missing bulk-IN as send-only rather than failing. - Keep the NATIVE_DLL_SEARCH_DIRECTORIES libusb-discovery fix. - Monitor: drop the separate send-only _usb field; USB connects as a BasePrinter and shares the StatusChanged / EnableAutomaticStatusBack path. Verified on a real printer (0471:0055): open + interface claim succeed, write returns with no SafeHandle error, and an ASB status block reads back and parses.
…image Regenerate test_receipt.txt via scripts/gen-test-receipt.py to exercise every supported feature: text styles (bold/italic/underline/font B/double sizes), alignment, all 1D barcodes (UPC-A, EAN-13/8, CODE39/93/128, ITF, CODABAR), all 2D symbols (QR, PDF417, DataMatrix, Aztec), an ESC * bit image, and the buzzer + cash drawer. Verified all barcodes/2D render without errors.
- New Monitor window (separate) using ESC-POS-.NET (NetworkPrinter + EPSON emitter) connects to the emulator over TCP and can: print a sample receipt, all 1D barcodes, QR/PDF417/DataMatrix/Aztec, the full feature test, open the drawer, buzz, and cut. - Subscribes to StatusChanged and enables Automatic Status Back, so toggling the Printer state panel updates the monitor's status display live. - Fix StatusByteBuilder.AutoStatusBack to match the ESC-POS-.NET 4-byte ASB parser (byte0 bit4 fixed, inverted drawer bit, paired paper-low/out bits). Verified end-to-end: paper-low/out, cover, drawer and offline all round-trip to PrinterStatusEventArgs. - 'Open monitor' button in the main window (single instance).
- Like a real printer, the emulator now refuses to print when not ready (out of paper, cover open, offline, or error): print/barcode/2D/bit-image ops are dropped and an OnPrintBlocked notification fires (shown as a toast). Notifies once per blocked episode and re-arms when the printer becomes ready again. - Monitor: replace the plain text status with colored indicator rows (dot + label + value) for Printer/Paper/Cover/Cash drawer/Error, plus a Ready/Not ready summary. Verified: printing blocked on paper-out/cover-open/offline and resumes when restored (single notification per episode).
LineFeed was not gated, so while printing was blocked the paper-feed commands still added empty lines (and cuts started receipts), producing blank receipt cards. Gate LineFeed on the ready state too; a not-ready printer now produces no output at all. Verified: sending the full test receipt with the cover open yields 0 receipts; ready yields output.
…fies The 'blocked' latch only reset when the printer returned to ready, so repeated print attempts while still not-ready (e.g. clicking Test print again with the cover open) were silently suppressed and showed no toast. Reset the latch at the start of each FeedEscPos so every received job that is blocked notifies once (still deduped within a single job). Verified: 3 separate blocked jobs -> 3 notifications; one multi-print job -> 1.
…gic constants
- Enable TreatWarningsAsErrors and NuGetAuditMode=all: build/CI now fails on any compiler,
analyzer, or NuGet security-audit warning (verified: a vulnerable transitive package
produces 'error NU1903: Warning As Error').
- Fix existing warnings:
- CA1416: repeat the OperatingSystem.IsWindows() guard inside the Task.Run lambda so
the Console.Beep call site is platform-checked; drop the annotated helper.
- NU1902/NU1903: override ESCPOS_NET's vulnerable transitive SixLabors.ImageSharp 2.1.3
with patched 2.1.13.
- NU1510: drop System.Text.Encoding.CodePages (provided by the framework on .NET 10;
code-page remapping still works).
- Replace magic numbers with named constants: DLE real-time sub-commands (EOT/ENQ/DC4/pulse)
and lengths, GS ( k function codes, a shared TwoDimensionCode (cn) type used by the printer,
the GS ( k command and the monitor, and BEL/module-size in the monitor.
Build is clean (0 warnings) in Debug and Release.
…e Monitor - Notifications (buzzer / cash drawer / print-blocked for text, barcode, QR, etc.) were cut short when several fired close together: each ShowToast scheduled its own one-shot timer and an earlier timer would hide a later toast. Use a single re-armable DispatcherTimer restarted on each toast, and lengthen the duration to 3.5s. - Remove the emulator's 'Test print' button/command: the emulator is the device and the Monitor window is the client that drives it over the wire (Send full feature test, all barcodes, QR/PDF417/DataMatrix/Aztec, drawer, buzz, cut). README updated to reflect this.
Add the monitor client screenshot and a side-by-side showing live status sync (the emulator's Printer state panel pushing paper-low / recoverable-error to the monitor, which then reports Not ready) to the Monitor section.
The Monitor window now offers a transport toggle (TCP/IP or Serial). Serial uses ESC-POS-.NET's SerialPrinter (port picker + baud, with a refresh button) sharing the same BasePrinter Write/StatusChanged path as the TCP NetworkPrinter, so sending jobs and the live status round-trip work over either transport. Pairs with the emulator's serial transport via a virtual port bridge (socat/com0com). Verified status parsing over serial.
The monitor's transport selector now offers TCP / Serial / USB. USB enumerates connected devices (VID:PID) via LibUsbDotNet/libusb, opens the selected one, claims its first interface and writes ESC/POS to the bulk-OUT endpoint — for printing directly to a real USB receipt printer that isn't exposed as a COM port. Send-only (no status read-back). - New UsbBulkTransport (enumerate / open by VID:PID / write); lazily loads libusb so the app still runs without it, and surfaces a clear error if libusb is missing or the OS already owns the device. - Monitor VM holds either an ESC-POS-.NET BasePrinter (TCP/serial) or the USB transport; Send writes to whichever is active. - README documents the USB transport and its native libusb requirement. Note: USB depends on native libusb and a real device, so it could not be exercised in CI.
LibUsbDotNet P/Invokes 'libusb-1.0', but .NET's default loader doesn't search Homebrew (/opt/homebrew/lib) or manual install paths, so an installed libusb still fails to load. Register a DllImportResolver on the LibUsbDotNet assembly that locates libusb across common macOS/Linux/Windows locations (and a copy shipped next to the executable), falling back to default resolution. Registered lazily in UsbBulkTransport's static ctor. Users still need libusb present (macOS: brew install libusb); the resolver makes an installed copy discoverable without extra DYLD/LD path configuration.
…vity log - When libusb can't be loaded (refresh or connect over USB), log a clear, multi-line message explaining USB is unavailable and how to install libusb for the current OS (brew/apt), instead of a raw exception. - Activity log entries are now selectable text, plus a 'Copy' button that copies the whole log (chronological) to the clipboard via Avalonia 12's clipboard API.
…f a resolver The previous DllImportResolver was registered on the LibUsbDotNet assembly — but LibUsbDotNet registers its OWN resolver on that same assembly in a static initializer, and the duplicate registration threw during its type init (surfacing as 'Unable to load libusb-1.0' even when libusb was installed). LibUsbDotNet's resolver searches NATIVE_DLL_SEARCH_DIRECTORIES (then the default OS path), which omits Homebrew/manual locations. So instead of a resolver, append the common libusb dirs (/opt/homebrew/lib, /opt/homebrew/opt/libusb/lib, /usr/local/lib, Linux lib dirs) to that AppContext list in UsbBulkTransport's static ctor, before any libusb call. No symlinks, no DYLD_LIBRARY_PATH. Verified: with libusb installed via brew, the app now enumerates USB devices (incl. 0471:0055).
…inter) Replace the send-only UsbBulkTransport with a UsbPrinter that derives from ESC-POS-.NET's BasePrinter, backed by a USB-endpoint Stream. Writes go to the bulk-OUT endpoint and status is read from the bulk-IN endpoint, so USB now joins the same write-queue + Automatic-Status-Back pipeline as the serial/TCP printers and the monitor reflects the printer's reported state over USB too. - Fix "SafeHandle cannot be null" on send: the device collection was disposed in Open(), invalidating the device handle before the write. Keep the collection + context alive for the connection's lifetime (owned by the stream, freed on Dispose). - UsbStream buffers bulk-IN reads (USB is packet-oriented; BasePrinter reads the status channel one byte at a time) and returns 0 on timeout so the read loop stays responsive without spinning. - Auto-detect bulk OUT/IN endpoints from the interface descriptors (direction via bEndpointAddress bit 7, type via bmAttributes); fall back to Ep01 for output, and treat a missing bulk-IN as send-only rather than failing. - Keep the NATIVE_DLL_SEARCH_DIRECTORIES libusb-discovery fix. - Monitor: drop the separate send-only _usb field; USB connects as a BasePrinter and shares the StatusChanged / EnableAutomaticStatusBack path. Verified on a real printer (0471:0055): open + interface claim succeed, write returns with no SafeHandle error, and an ASB status block reads back and parses.
…port while connected Disconnecting a USB session could hard-crash the app: BasePrinter's background read loop issues a native libusb bulk read, and Dispose freed the device/context (via the reader/writer close) while that read was still in flight — a native use-after-free no managed catch can stop. - UsbStream now serializes all native I/O and the device/context teardown under a single lock, with a _closing flag that short-circuits any read/write issued during/after teardown so it never touches a freed handle. Dispose flags first, then takes the lock to wait out any in-flight transfer (bounded by the I/O timeouts) before freeing the device and context. Verified on a real printer (0471:0055): 5 connect/disconnect cycles with the read loop active survive, and status still reads back. - Logger: persist messages to a rolling log file (LocalApplicationData/ CrossEscPosEmulator/logs/app.log) and add InstallGlobalHandlers() for AppDomain.UnhandledException + TaskScheduler.UnobservedTaskException (the latter SetObserved so a faulted background task can't silently kill the process). Installed first thing in Program.Main. - Monitor: lock the transport selector and the TCP/Serial/USB endpoint fields while connected so the transport can't be switched mid-session.
Fix app crash on USB disconnect; log unhandled exceptions; lock transport while connected
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This ports the emulator from Windows-only WPF to a cross-platform Avalonia 12 + SkiaSharp + .NET 10 app that runs on Windows, macOS and Linux, and extends it with a few commonly requested features. Opening it upstream in case it's useful — happy to split it into smaller PRs or adjust anything if you'd prefer to take it in pieces.
Core port
net9.0-windows→net10.0(noUseWPF).New features
GS k): UPC-A/E, EAN-13/8, CODE39/93/128, ITF, CODABAR (both function A & B forms) with HRI text — via ZXing.Net.GS ( k, cn=49): model / module size / EC level / store / print — via QRCoder.ESC p) and buzzer (BEL) — surfaced as a sound + on-screen toast.Packaging
.appbundle) and attaches them to a release on eachvX.X.Xtag.Notes