Vendor Desktop Architecture
Executive summary
Section titled “Executive summary”Vendor Desktop (apps/vendor-desktop) is an Electron 34 application that packages the cashier-only React UI from apps/vendor-web with local-first capabilities: offline sales via SQLite cache and order outbox, POS Bridge Agent for payment terminal relay, receipt/label printing, hardware integration (scales, scanners, customer display), and auto-update delivery. The main process exposes 42 IPC channels to the renderer for hardware abstraction, and the app supports single-instance enforcement, launch at login, and configurable update feeds for staging vs production.
Goals and non-goals
Section titled “Goals and non-goals”| Goals | Non-goals |
|---|---|
| Document Electron architecture and IPC contract | Replace end-user cashier guides (see Cashier guides) |
| Explain offline sync, bridge agent, and printer integration | Document backend POS API (see POS payments & webhooks) |
| Provide development and troubleshooting guidance | Duplicate Electron.js official docs |
Architecture overview
Section titled “Architecture overview”Three-process model
Section titled “Three-process model”flowchart TB
Main[Main Process<br/>Node.js<br/>main.ts]
Preload[Preload Script<br/>Context Bridge<br/>preload.ts]
Renderer[Renderer Process<br/>React 19 + Vite<br/>from vendor-web]
Main -->|IPC| Preload
Preload -->|window.electron API| Renderer
Main ---|Manages| Windows[BrowserWindow instances]
Main ---|Connects to| Backend[LionPOS Backend API]
Main ---|Runs| Bridge[POS Bridge Agent<br/>WebSocket]
Main ---|Manages| DB[Local SQLite<br/>pos-local-db.ts]
Main ---|Controls| Hardware[Printers, Scales,<br/>Scanners, Display]
Key components
Section titled “Key components”| Component | File | Responsibility |
|---|---|---|
| Main process | src/main.ts | App lifecycle, window management, IPC handlers (42 channels) |
| Preload script | src/preload.ts | Context bridge - exposes window.electron API to renderer |
| POS Bridge Agent | src/pos-bridge-agent.ts | WebSocket relay for payment terminals (PAX TCP, CodePay WS) |
| Local Database | src/pos-local-db.ts | SQLite catalog cache + order outbox for offline mode |
| Printer Manager | src/printer.ts | HTML-to-print, ESC/POS raw printing, cash drawer kick |
| ECR Hub Adapter | src/ecr-hub-adapter.ts | Terminal command relay (uses @indochina/ecr-hub) |
| Renderer | apps/vendor-web (cashier build) | React UI loaded into BrowserWindow |
Window management
Section titled “Window management”Main window
Section titled “Main window”Created in: src/main.ts → createMainWindow()
Configuration:
- Title: “Cashier” (auto-sets from package.json
productName) - Size: 1280×720 default, remembers user resize via
electron-window-state - Icon: Platform-specific (see
getAppIcon()) - Web preferences:
nodeIntegration: false(security)contextIsolation: true(security)preload: path.join(__dirname, 'preload.js')(context bridge)webSecurity: truein production
Lifecycle:
- App ready → create window
- Load renderer (file:// or dev server URL)
- IPC handlers registered before window creation
- Single-instance lock enforced (see § Single-instance)
Customer display window (secondary)
Section titled “Customer display window (secondary)”Created on demand: IPC channel customer-display:open
Configuration:
- Opens on second monitor when available
- Fullscreen, frameless
- Loads
/pos/customer-displayroute - Managed separately from main window
IPC channels (42 total)
Section titled “IPC channels (42 total)”Channel categories
Section titled “Channel categories”| Category | Channels | Purpose |
|---|---|---|
| Window control | 5 | Minimize, maximize, close, fullscreen, customer display |
| Network diagnostics | 2 | TCP probe, WebSocket probe |
| POS Bridge Agent | 8 | Start, stop, status, config, relay commands |
| Local Database | 12 | Catalog sync, outbox management, draft storage |
| Printing | 6 | Receipt, label, raw ESC/POS, preview |
| Hardware | 5 | Scale read, scanner events, drawer kick |
| Customer Display | 2 | Open window, send content |
| App Updates | 2 | Check, download, install |
Complete IPC reference
Section titled “Complete IPC reference”Window control (src/main.ts):
window:minimize→ Minimize main windowwindow:maximize→ Toggle maximize/unmaximizewindow:close→ Close main windowwindow:fullscreen→ Toggle fullscreencustomer-display:open→ Open customer display on second monitor
Network diagnostics (src/main.ts):
network:probe-tcp→ Test TCP connectivity (host, port, timeout)- Args:
{ host: string, port: number, timeoutMs?: number } - Returns:
{ success: boolean, latencyMs?: number, error?: string }
- Args:
network:probe-ws→ Test WebSocket connectivity- Args:
{ url: string, timeoutMs?: number } - Returns:
{ success: boolean, latencyMs?: number, error?: string }
- Args:
POS Bridge Agent (src/main.ts + pos-bridge-agent.ts):
pos-bridge:start→ Start WebSocket connection to backend- Args:
{ wsUrl: string, terminalId: string, storeId?: string } - Returns:
{ success: boolean, sessionKey: string }
- Args:
pos-bridge:stop→ Stop active connection- Args:
{ sessionKey: string }
- Args:
pos-bridge:status→ Get connection status- Returns:
{ connected: boolean, sessions: Record<string, SessionInfo> }
- Returns:
pos-bridge:send-payment-result→ Send terminal payment result- Args:
TerminalPaymentResult(see § POS Bridge Agent)
- Args:
pos-bridge:relay-ecr-command→ Relay ECR command to terminal- Args:
{ command: EcrHubCommand, terminalConfig: TerminalConfig } - Returns:
{ success: boolean, result?: EcrHubResult }
- Args:
pos-bridge:get-logs→ Retrieve recent bridge logspos-bridge:clear-logs→ Clear bridge logspos-bridge:set-config→ Update bridge configuration
Local Database (src/pos-local-db.ts):
local-db:init→ Initialize SQLite database for store- Args:
{ storeId: string, reset?: boolean }
- Args:
local-db:sync-catalog→ Cache catalog snapshot- Args:
{ storeId: string, catalog: CatalogSnapshot }
- Args:
local-db:get-catalog→ Retrieve cached catalog- Returns:
{ items: Item[], categories: Category[], timestamp: number }
- Returns:
local-db:add-to-outbox→ Queue order for sync- Args:
{ order: OrderPayload, clientOrderId: string }
- Args:
local-db:get-outbox→ List pending orderslocal-db:remove-from-outbox→ Remove synced orderlocal-db:clear-outbox→ Clear all outbox entrieslocal-db:save-draft→ Save named draft- Args:
{ draftId: string, cart: CartState }
- Args:
local-db:get-drafts→ List saved draftslocal-db:delete-draft→ Remove draftlocal-db:get-stats→ Database stats (size, row counts)local-db:reset→ Clear all local data with backup
Printing (src/printer.ts):
printer:print-receipt→ Print HTML receipt- Args:
{ html: string, silent?: boolean, openDrawer?: boolean } - Returns:
{ success: boolean, error?: string }
- Args:
printer:print-label→ Print barcode label- Args:
{ sku: string, name: string, price: number, barcodeFormat: string }
- Args:
printer:print-raw-escpos→ Send raw ESC/POS commands (Windows only)- Args:
{ printerName: string, commands: Buffer }
- Args:
printer:preview-receipt→ Show print preview dialogprinter:get-printers→ List available printers- Returns:
{ printers: Array<{ name: string, isDefault: boolean }> }
- Returns:
printer:kick-drawer→ Open cash drawer via printer pulse
Hardware (src/main.ts + helpers):
scale:read-weight→ Read weight from USB/Serial scale- Args:
{ devicePath: string, protocol: 'hid' | 'serial' } - Returns:
{ weight: number, unit: 'kg' | 'lb', stable: boolean }
- Args:
scanner:listen→ Subscribe to barcode scanner events (keyboard wedge)- Event:
scanner:barcode-scanned→{ barcode: string, timestamp: number }
- Event:
scanner:stop→ Unsubscribe from scanner eventsdrawer:kick→ Alias forprinter:kick-drawerhardware:list-devices→ Enumerate USB HID devices
Customer Display (src/main.ts):
customer-display:open→ Open display window on second monitorcustomer-display:send→ Push content to display- Args:
{ type: 'cart' | 'total' | 'idle', data: any }
- Args:
App Updates (src/main.ts + electron-updater):
app:check-for-updates→ Check for new version- Returns:
{ available: boolean, version?: string, releaseNotes?: string }
- Returns:
app:download-and-install→ Download and quit-and-install update
IPC usage pattern (renderer side)
Section titled “IPC usage pattern (renderer side)”From apps/vendor-web renderer:
// Type-safe IPC call via preload context bridgeconst result = await window.electron.invoke('pos-bridge:start', { wsUrl: 'wss://api.example.com/pos-bridge/ws?storeId=123&token=xyz', terminalId: 'term-456', storeId: '123'});
if (result.success) { console.log('Bridge started:', result.sessionKey);}
// Subscribe to eventswindow.electron.on('scanner:barcode-scanned', (event, data) => { console.log('Scanned:', data.barcode);});POS Bridge Agent
Section titled “POS Bridge Agent”Purpose
Section titled “Purpose”The POS Bridge Agent is a WebSocket client running in the main process that:
- Connects to
POST /api/v1/pos-bridge/activate→ receiveswsUrl,webhookUrl,terminalId - Opens WebSocket to backend (
/api/v1/pos-bridge/ws) - Relays payment_request from server → terminal (PAX TCP or CodePay WS)
- Sends payment_result back via WebSocket or HTTP webhook
Architecture
Section titled “Architecture”flowchart LR
UI[Cashier UI<br/>Renderer] -->|IPC| Main[Main Process<br/>Bridge Agent]
Main <-->|WebSocket| Backend[LionPOS API<br/>pos-bridge/ws]
Main <-->|TCP / WS| Terminal[Payment Terminal<br/>PAX or CodePay]
Main -->|HTTP POST| Webhook[Terminal Webhook<br/>API]
Terminal relay modes
Section titled “Terminal relay modes”| Mode | Protocol | Config | Use Case |
|---|---|---|---|
| PAX LAN | TCP (STX/ETX/FS/LRC) | host, port | Physical PAX terminal on store LAN |
| CodePay WS | WebSocket (JSON) | wsUrl | Cloud payment terminal or virtual |
Activation flow
Section titled “Activation flow”- Renderer calls
POST /api/v1/vendor/settings/devices/pos-bridge/activate(vendor JWT) withdeviceIdfrom Settings → Devices - Backend returns:
{"wsUrl": "wss://api.example.com/api/v1/pos-bridge/ws?storeId=123&token=store-bridge-key","webhookUrl": "https://api.example.com/api/v1/pos/payments/terminal-webhook","terminalId": "store_devices.id","terminalConfig": { "host": "192.168.1.10", "port": 10009 }}
- Renderer stores config in
localStorage(vendor.posBridgeAgent.*.v1) - Renderer calls IPC
pos-bridge:startwithwsUrl,terminalId, optionalstoreId - Main process opens WebSocket, listens for
payment_request,ecr_relay_request,printer_print_request
Payment flow
Section titled “Payment flow”Happy path:
- Backend emits
payment_requeston WebSocket:{"type": "payment_request","orderId": "123","amount": 4500,"currency": "USD","orderReference": "123","transactionRef": "1726650000123","storeDeviceId": "term-456"} - Bridge agent relays to terminal via PAX TCP
SALEor CodePay WSecrhub.sale - Terminal approves → agent receives auth code, card last4, etc.
- Agent sends
payment_resulton WebSocket:{"type": "payment_result","orderReference": "123","paymentReference": "1726650000123","outcome": "succeeded","authCode": "ABC123","cardLast4": "4242","cardBrand": "Visa","entryMode": "CHIP"} - Backend finalizes order → renderer polling detects success
Webhook fallback:
- If WebSocket drops, agent POSTs to
webhookUrlwith headerx-pos-terminal-key: <store bridge key>
Session management
Section titled “Session management”Multi-terminal support:
- One WebSocket per store (preferred):
wsUrlincludesstoreId, all terminals on that store share one socket - Legacy: One socket per
terminalId(still supported)
Session keys:
- Format:
store:<storeId>orterminal:<terminalId> - Stored in renderer localStorage for reconnection after app restart
Auto-start:
- On app launch, renderer reads all saved bridge configs from
nipos.vendor.posBridge.deviceSnapshots.v1 - Calls
pos-bridge:startfor each to reconnect
Logging
Section titled “Logging”Bridge logs stored in-memory (recent 1000 entries):
pos-bridge:get-logs→ retrieve for debugging- Includes: connection events, payment requests/results, relay errors
Local Database (SQLite)
Section titled “Local Database (SQLite)”Schema
Section titled “Schema”Database location: <userData>/pos-local-{storeId}.db (per store)
Tables:
CREATE TABLE catalog_items ( id TEXT PRIMARY KEY, sku TEXT, name TEXT, price REAL, category_id TEXT, barcode TEXT, image_url TEXT, stock INTEGER, is_active INTEGER, metadata TEXT -- JSON);
CREATE TABLE catalog_categories ( id TEXT PRIMARY KEY, name TEXT, parent_id TEXT);
CREATE TABLE outbox ( id INTEGER PRIMARY KEY AUTOINCREMENT, client_order_id TEXT UNIQUE, order_payload TEXT, -- JSON created_at INTEGER, -- Unix timestamp retry_count INTEGER DEFAULT 0);
CREATE TABLE drafts ( id TEXT PRIMARY KEY, name TEXT, cart_state TEXT, -- JSON created_at INTEGER, updated_at INTEGER);
CREATE TABLE sync_metadata ( key TEXT PRIMARY KEY, value TEXT);Catalog sync
Section titled “Catalog sync”Trigger: Renderer calls local-db:sync-catalog when catalog changes detected or on app start
Flow:
- Renderer fetches catalog from backend (uses vendor APIs or dedicated sync endpoint)
- Passes full snapshot to
local-db:sync-catalog - Main process:
- Wraps in transaction
- Deletes old
catalog_itemsandcatalog_categories - Inserts new snapshot
- Updates
sync_metadata.last_sync_at
Query: local-db:get-catalog → returns cached items/categories for offline checkout
Order outbox
Section titled “Order outbox”Purpose: Queue orders when backend API is unreachable
Add to outbox:
await window.electron.invoke('local-db:add-to-outbox', { order: { items: [...], customer_id: '123', payment_method: 'cash', total: 4500 }, clientOrderId: 'local-1726650000-abc'});Sync outbox:
- Renderer calls
local-db:get-outbox→ receives array of pending orders - For each order:
- POST to
/api/v1/pos/orderswithclient_order_id - On success:
local-db:remove-from-outbox - On 4xx (duplicate):
local-db:remove-from-outbox - On 5xx: increment
retry_count, retry with exponential backoff
- POST to
Display codes:
- Outbox orders use temp display codes (e.g.
LOCAL-123) - Backend assigns real order ID on sync
- Renderer updates UI when sync completes
Named drafts
Section titled “Named drafts”Save draft:
await window.electron.invoke('local-db:save-draft', { draftId: 'draft-abc-123', cart: { items: [...], notes: '...' }});Load drafts: local-db:get-drafts → list all, renderer shows in Drafts tab
Printer integration
Section titled “Printer integration”Receipt printing (HTML-to-print)
Section titled “Receipt printing (HTML-to-print)”Flow:
- Renderer receives receipt HTML from backend (e.g.,
GET /vendor/orders/:id/receipt-html) - Calls
printer:print-receiptwith HTML +silent: true(auto-print) orfalse(preview dialog) - Main process:
- Creates hidden BrowserWindow
- Loads HTML
- Calls
webContents.print()with thermal printer settings - Auto-opens cash drawer if
openDrawer: true
Printer settings (80mm thermal):
{ silent: true, // no dialog deviceName: 'default', // or specific printer pageSize: { width: 80000, height: 0 }, // microns, height auto margins: { marginType: 'none' }}Label printing
Section titled “Label printing”Used for: Barcode labels (items, lots) - see Barcode labels
Flow: Similar to receipt, uses label template HTML (smaller page size)
Raw ESC/POS (Windows only)
Section titled “Raw ESC/POS (Windows only)”Purpose: Direct ESC/POS command printing without HTML rendering
Usage:
const commands = Buffer.from([0x1B, 0x40, ...]); // ESC/POS bytesawait window.electron.invoke('printer:print-raw-escpos', { printerName: 'TM-T88V', commands});Limitation: Requires Windows raw printing API; macOS/Linux use HTML path
Cash drawer kick
Section titled “Cash drawer kick”Trigger: After successful cash sale or manual button in UI
Command: Sends ESC/POS pulse to printer (DK I or DK II pin)
IPC: printer:kick-drawer
Hardware integration
Section titled “Hardware integration”Weighing scales
Section titled “Weighing scales”Supported protocols:
- USB HID: Most USB scales (e.g., Mettler Toledo, A&D)
- Serial: RS-232 scales via USB-to-serial adapter
Read weight:
const result = await window.electron.invoke('scale:read-weight', { devicePath: '/dev/tty.usbserial-A1234', // or COM3 on Windows protocol: 'serial'});// { weight: 0.523, unit: 'kg', stable: true }Use case: Weighted items (grocery) - staff places item on scale, reads weight, calculates price
Barcode scanners (keyboard wedge)
Section titled “Barcode scanners (keyboard wedge)”How it works:
- Most USB scanners emulate keyboard input
- Renderer listens for rapid keypress sequences ending in Enter
- Filters out regular typing
IPC subscription:
window.electron.on('scanner:barcode-scanned', (event, { barcode }) => { // Add scanned item to cart});
await window.electron.invoke('scanner:listen');Scanner types supported:
- USB keyboard wedge (most common)
- Bluetooth scanners (if paired as HID keyboard)
Customer display (secondary monitor)
Section titled “Customer display (secondary monitor)”Purpose: Show cart total, line items, promotional content to customer
Flow:
- Renderer calls
customer-display:open - Main creates second BrowserWindow on monitor 2 (if available)
- Window loads
/pos/customer-displayroute (fullscreen, frameless) - Renderer sends cart updates via
customer-display:send
Content types:
cart: Line items + subtotaltotal: Final total + payment methodidle: Slideshow banners (configured in Settings → Slideshow)
Auto-update system
Section titled “Auto-update system”Update feed
Section titled “Update feed”Configuration (build-time via scripts/write-update-feed-url.mjs):
Environment variables (see Environment configuration):
VENDOR_DESKTOP_UPDATE_FEED_URL→ production feedVENDOR_DESKTOP_UPDATE_FEED_URL_STAGING→ staging feedVENDOR_DESKTOP_PACK_PROFILE→stagingor omit for production
Feed format (JSON served at feed URL):
{ "version": "1.2.3", "releaseDate": "2026-09-18", "url": "https://cdn.example.com/releases/vendor-desktop-1.2.3-mac-arm64.dmg", "sha512": "abc123...", "releaseNotes": "Bug fixes and performance improvements"}Update flow
Section titled “Update flow”- On app start (after 30s delay) or manual check →
app:check-for-updates electron-updaterfetches feed JSON, compares version- If newer version available → notify renderer
- User clicks “Download and Install” →
app:download-and-install - Download to temp, verify SHA-512, quit and install
macOS: DMG or ZIP with auto-update Windows: NSIS installer with auto-update
Staging vs production
Section titled “Staging vs production”- Production:
npm run pack→ usesVENDOR_DESKTOP_UPDATE_FEED_URL - Staging:
VENDOR_DESKTOP_PACK_PROFILE=staging npm run pack→ uses_STAGINGURL
Development workflow
Section titled “Development workflow”Prerequisites
Section titled “Prerequisites”- Node.js 20+ (matches backend)
- Shared workspace dependencies installed (
npm installfrom monorepo root)
Local development
Section titled “Local development”Terminal 1 (vendor-web renderer dev server):
cd apps/vendor-webnpm run dev:electron-cashier-renderer# Vite dev server on http://localhost:5180 (cashier-only routes)Terminal 2 (Electron main + preload with hot reload):
cd apps/vendor-desktopnpm run dev# Watches main.ts, preload.ts, rebuilds on change, launches ElectronDevTools:
- Main process:
Cmd+Shift+IorView → Toggle Developer Tools - Opens Chromium DevTools for renderer inspection
Environment variables (dev):
VENDOR_DESKTOP_DEV=1→ enables verbose logging, skips some security checksVENDOR_DESKTOP_RENDERER_DEV_PORT=5180→ where to load renderer from
Building
Section titled “Building”Full build (production):
cd apps/vendor-webnpm run build:electron-cashier-renderer# Outputs to apps/vendor-desktop/dist/renderer/
cd apps/vendor-desktopnpm run pack# Electron-builder packages .app (macOS) or .exe (Windows)# Output: dist/Cashier-1.2.3-arm64.dmg (or .exe)Build artifacts:
- macOS: DMG, ZIP, or PKG
- Windows: NSIS installer (exe), portable (exe)
- Config:
apps/vendor-desktop/package.json→buildsection
Debugging
Section titled “Debugging”Main process logs:
- Console output in terminal where
npm run devlaunched - macOS:
~/Library/Logs/Cashier/main.log - Windows:
%APPDATA%\Cashier\logs\main.log
Renderer logs:
- DevTools console (
Cmd+Shift+I)
IPC tracing:
- Add
console.login preload or main IPC handlers - Use
ipcMain.onvsipcMain.handledistinction (events vs request/response)
Security
Section titled “Security”Context isolation
Section titled “Context isolation”Enabled: contextIsolation: true in webPreferences
What this means:
- Renderer cannot access Node.js or Electron APIs directly
- All IPC must go through preload script’s
contextBridge.exposeInMainWorld - Prevents malicious scripts from accessing file system or running shell commands
Node integration
Section titled “Node integration”Disabled: nodeIntegration: false
Rationale: Renderer should not have Node.js require() - all privileged operations via IPC
Web security
Section titled “Web security”Enabled in production: webSecurity: true
Dev mode: May be relaxed for CORS with local API (use VENDOR_DESKTOP_DEV=1)
API key storage
Section titled “API key storage”Local storage (renderer):
- JWT tokens stored in
localStorage(same as browser vendor-web) - Bridge configs include store bridge API key (store-scoped, not user JWT)
Recommendation: Use short-lived JWTs, refresh tokens
Troubleshooting
Section titled “Troubleshooting”Common issues
Section titled “Common issues”| Symptom | Likely Cause | Solution |
|---|---|---|
| App won’t start | Single-instance lock | Quit existing instance, check Activity Monitor / Task Manager |
| Blank white screen | Renderer failed to load | Check dev server running (port 5180), inspect DevTools console |
| IPC call returns undefined | Handler not registered or typo | Verify ipcMain.handle('channel-name', ...) in main.ts |
| Bridge won’t connect | Wrong wsUrl or firewall | Test WebSocket with network:probe-ws, check backend logs |
| Print fails silently | No default printer or wrong device | Call printer:get-printers, set default in OS |
| Scale not reading | Wrong protocol or device path | List USB devices with hardware:list-devices, verify path |
| Outbox orders not syncing | Network down or backend error | Check local-db:get-outbox logs, retry with backend online |
| Catalog stale | Sync not triggered | Call local-db:sync-catalog from renderer or reset local data |
| Auto-update stuck | Bad feed URL or network | Check update feed JSON manually, verify SHA-512 |
Logs and diagnostics
Section titled “Logs and diagnostics”Retrieve POS bridge logs:
const logs = await window.electron.invoke('pos-bridge:get-logs');console.log(logs); // Array of { timestamp, level, message }Database stats:
const stats = await window.electron.invoke('local-db:get-stats');// { dbSizeBytes, itemCount, outboxCount, draftCount }Network diagnostics:
// Test backend API reachabilityconst tcpProbe = await window.electron.invoke('network:probe-tcp', { host: 'api.example.com', port: 443, timeoutMs: 5000});
// Test WebSocketconst wsProbe = await window.electron.invoke('network:probe-ws', { url: 'wss://api.example.com/api/v1/pos-bridge/ws', timeoutMs: 5000});Reset local data
Section titled “Reset local data”When to use: Corrupt database, wrong store, after testing
Flow:
- User → Settings → Local data → Reset
- Renderer calls
local-db:resetwith{ storeId, backup: true } - Main process:
- Creates backup:
pos-local-{storeId}-backup-{timestamp}.db - Deletes original DB
- Re-initializes empty schema
- Creates backup:
Caution: Outbox orders are lost (ensure synced first)
Related
Section titled “Related”- Frontend applications — Packaging and build commands
- Environment configuration — Vendor Desktop env vars
- POS terminal PAX integration — Bridge WebSocket contract
- Offline and local data (cashier) — User-facing guide
- Receipt printer (cashier) — End-user setup