Skip to content

Vendor Desktop Architecture

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.


GoalsNon-goals
Document Electron architecture and IPC contractReplace end-user cashier guides (see Cashier guides)
Explain offline sync, bridge agent, and printer integrationDocument backend POS API (see POS payments & webhooks)
Provide development and troubleshooting guidanceDuplicate Electron.js official docs

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]
ComponentFileResponsibility
Main processsrc/main.tsApp lifecycle, window management, IPC handlers (42 channels)
Preload scriptsrc/preload.tsContext bridge - exposes window.electron API to renderer
POS Bridge Agentsrc/pos-bridge-agent.tsWebSocket relay for payment terminals (PAX TCP, CodePay WS)
Local Databasesrc/pos-local-db.tsSQLite catalog cache + order outbox for offline mode
Printer Managersrc/printer.tsHTML-to-print, ESC/POS raw printing, cash drawer kick
ECR Hub Adaptersrc/ecr-hub-adapter.tsTerminal command relay (uses @indochina/ecr-hub)
Rendererapps/vendor-web (cashier build)React UI loaded into BrowserWindow

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: true in production

Lifecycle:

  1. App ready → create window
  2. Load renderer (file:// or dev server URL)
  3. IPC handlers registered before window creation
  4. Single-instance lock enforced (see § Single-instance)

Created on demand: IPC channel customer-display:open

Configuration:

  • Opens on second monitor when available
  • Fullscreen, frameless
  • Loads /pos/customer-display route
  • Managed separately from main window

CategoryChannelsPurpose
Window control5Minimize, maximize, close, fullscreen, customer display
Network diagnostics2TCP probe, WebSocket probe
POS Bridge Agent8Start, stop, status, config, relay commands
Local Database12Catalog sync, outbox management, draft storage
Printing6Receipt, label, raw ESC/POS, preview
Hardware5Scale read, scanner events, drawer kick
Customer Display2Open window, send content
App Updates2Check, download, install

Window control (src/main.ts):

  • window:minimize → Minimize main window
  • window:maximize → Toggle maximize/unmaximize
  • window:close → Close main window
  • window:fullscreen → Toggle fullscreen
  • customer-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 }
  • network:probe-ws → Test WebSocket connectivity
    • Args: { url: string, timeoutMs?: number }
    • Returns: { success: boolean, latencyMs?: number, error?: string }

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 }
  • pos-bridge:stop → Stop active connection
    • Args: { sessionKey: string }
  • pos-bridge:status → Get connection status
    • Returns: { connected: boolean, sessions: Record<string, SessionInfo> }
  • pos-bridge:send-payment-result → Send terminal payment result
    • Args: TerminalPaymentResult (see § POS Bridge Agent)
  • pos-bridge:relay-ecr-command → Relay ECR command to terminal
    • Args: { command: EcrHubCommand, terminalConfig: TerminalConfig }
    • Returns: { success: boolean, result?: EcrHubResult }
  • pos-bridge:get-logs → Retrieve recent bridge logs
  • pos-bridge:clear-logs → Clear bridge logs
  • pos-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 }
  • local-db:sync-catalog → Cache catalog snapshot
    • Args: { storeId: string, catalog: CatalogSnapshot }
  • local-db:get-catalog → Retrieve cached catalog
    • Returns: { items: Item[], categories: Category[], timestamp: number }
  • local-db:add-to-outbox → Queue order for sync
    • Args: { order: OrderPayload, clientOrderId: string }
  • local-db:get-outbox → List pending orders
  • local-db:remove-from-outbox → Remove synced order
  • local-db:clear-outbox → Clear all outbox entries
  • local-db:save-draft → Save named draft
    • Args: { draftId: string, cart: CartState }
  • local-db:get-drafts → List saved drafts
  • local-db:delete-draft → Remove draft
  • local-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 }
  • printer:print-label → Print barcode label
    • Args: { sku: string, name: string, price: number, barcodeFormat: string }
  • printer:print-raw-escpos → Send raw ESC/POS commands (Windows only)
    • Args: { printerName: string, commands: Buffer }
  • printer:preview-receipt → Show print preview dialog
  • printer:get-printers → List available printers
    • Returns: { printers: Array<{ name: string, isDefault: boolean }> }
  • 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 }
  • scanner:listen → Subscribe to barcode scanner events (keyboard wedge)
    • Event: scanner:barcode-scanned → { barcode: string, timestamp: number }
  • scanner:stop → Unsubscribe from scanner events
  • drawer:kick → Alias for printer:kick-drawer
  • hardware:list-devices → Enumerate USB HID devices

Customer Display (src/main.ts):

  • customer-display:open → Open display window on second monitor
  • customer-display:send → Push content to display
    • Args: { type: 'cart' | 'total' | 'idle', data: any }

App Updates (src/main.ts + electron-updater):

  • app:check-for-updates → Check for new version
    • Returns: { available: boolean, version?: string, releaseNotes?: string }
  • app:download-and-install → Download and quit-and-install update

From apps/vendor-web renderer:

// Type-safe IPC call via preload context bridge
const 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 events
window.electron.on('scanner:barcode-scanned', (event, data) => {
console.log('Scanned:', data.barcode);
});

The POS Bridge Agent is a WebSocket client running in the main process that:

  1. Connects to POST /api/v1/pos-bridge/activate → receives wsUrl, webhookUrl, terminalId
  2. Opens WebSocket to backend (/api/v1/pos-bridge/ws)
  3. Relays payment_request from server → terminal (PAX TCP or CodePay WS)
  4. Sends payment_result back via WebSocket or HTTP webhook
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]
ModeProtocolConfigUse Case
PAX LANTCP (STX/ETX/FS/LRC)host, portPhysical PAX terminal on store LAN
CodePay WSWebSocket (JSON)wsUrlCloud payment terminal or virtual
  1. Renderer calls POST /api/v1/vendor/settings/devices/pos-bridge/activate (vendor JWT) with deviceId from Settings → Devices
  2. 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 }
    }
  3. Renderer stores config in localStorage (vendor.posBridgeAgent.*.v1)
  4. Renderer calls IPC pos-bridge:start with wsUrl, terminalId, optional storeId
  5. Main process opens WebSocket, listens for payment_request, ecr_relay_request, printer_print_request

Happy path:

  1. Backend emits payment_request on WebSocket:
    {
    "type": "payment_request",
    "orderId": "123",
    "amount": 4500,
    "currency": "USD",
    "orderReference": "123",
    "transactionRef": "1726650000123",
    "storeDeviceId": "term-456"
    }
  2. Bridge agent relays to terminal via PAX TCP SALE or CodePay WS ecrhub.sale
  3. Terminal approves → agent receives auth code, card last4, etc.
  4. Agent sends payment_result on WebSocket:
    {
    "type": "payment_result",
    "orderReference": "123",
    "paymentReference": "1726650000123",
    "outcome": "succeeded",
    "authCode": "ABC123",
    "cardLast4": "4242",
    "cardBrand": "Visa",
    "entryMode": "CHIP"
    }
  5. Backend finalizes order → renderer polling detects success

Webhook fallback:

  • If WebSocket drops, agent POSTs to webhookUrl with header x-pos-terminal-key: <store bridge key>

Multi-terminal support:

  • One WebSocket per store (preferred): wsUrl includes storeId, all terminals on that store share one socket
  • Legacy: One socket per terminalId (still supported)

Session keys:

  • Format: store:<storeId> or terminal:<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:start for each to reconnect

Bridge logs stored in-memory (recent 1000 entries):

  • pos-bridge:get-logs → retrieve for debugging
  • Includes: connection events, payment requests/results, relay errors

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
);

Trigger: Renderer calls local-db:sync-catalog when catalog changes detected or on app start

Flow:

  1. Renderer fetches catalog from backend (uses vendor APIs or dedicated sync endpoint)
  2. Passes full snapshot to local-db:sync-catalog
  3. Main process:
    • Wraps in transaction
    • Deletes old catalog_items and catalog_categories
    • Inserts new snapshot
    • Updates sync_metadata.last_sync_at

Query: local-db:get-catalog → returns cached items/categories for offline checkout

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:

  1. Renderer calls local-db:get-outbox → receives array of pending orders
  2. For each order:
    • POST to /api/v1/pos/orders with client_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

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

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


Flow:

  1. Renderer receives receipt HTML from backend (e.g., GET /vendor/orders/:id/receipt-html)
  2. Calls printer:print-receipt with HTML + silent: true (auto-print) or false (preview dialog)
  3. 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' }
}

Used for: Barcode labels (items, lots) - see Barcode labels

Flow: Similar to receipt, uses label template HTML (smaller page size)

Purpose: Direct ESC/POS command printing without HTML rendering

Usage:

const commands = Buffer.from([0x1B, 0x40, ...]); // ESC/POS bytes
await window.electron.invoke('printer:print-raw-escpos', {
printerName: 'TM-T88V',
commands
});

Limitation: Requires Windows raw printing API; macOS/Linux use HTML path

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


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

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)

Purpose: Show cart total, line items, promotional content to customer

Flow:

  1. Renderer calls customer-display:open
  2. Main creates second BrowserWindow on monitor 2 (if available)
  3. Window loads /pos/customer-display route (fullscreen, frameless)
  4. Renderer sends cart updates via customer-display:send

Content types:

  • cart: Line items + subtotal
  • total: Final total + payment method
  • idle: Slideshow banners (configured in Settings → Slideshow)

Configuration (build-time via scripts/write-update-feed-url.mjs):

Environment variables (see Environment configuration):

  • VENDOR_DESKTOP_UPDATE_FEED_URL → production feed
  • VENDOR_DESKTOP_UPDATE_FEED_URL_STAGING → staging feed
  • VENDOR_DESKTOP_PACK_PROFILE → staging or 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"
}
  1. On app start (after 30s delay) or manual check → app:check-for-updates
  2. electron-updater fetches feed JSON, compares version
  3. If newer version available → notify renderer
  4. User clicks “Download and Install” → app:download-and-install
  5. Download to temp, verify SHA-512, quit and install

macOS: DMG or ZIP with auto-update Windows: NSIS installer with auto-update

  • Production: npm run pack → uses VENDOR_DESKTOP_UPDATE_FEED_URL
  • Staging: VENDOR_DESKTOP_PACK_PROFILE=staging npm run pack → uses _STAGING URL

  • Node.js 20+ (matches backend)
  • Shared workspace dependencies installed (npm install from monorepo root)

Terminal 1 (vendor-web renderer dev server):

Terminal window
cd apps/vendor-web
npm run dev:electron-cashier-renderer
# Vite dev server on http://localhost:5180 (cashier-only routes)

Terminal 2 (Electron main + preload with hot reload):

Terminal window
cd apps/vendor-desktop
npm run dev
# Watches main.ts, preload.ts, rebuilds on change, launches Electron

DevTools:

  • Main process: Cmd+Shift+I or View → Toggle Developer Tools
  • Opens Chromium DevTools for renderer inspection

Environment variables (dev):

  • VENDOR_DESKTOP_DEV=1 → enables verbose logging, skips some security checks
  • VENDOR_DESKTOP_RENDERER_DEV_PORT=5180 → where to load renderer from

Full build (production):

Terminal window
cd apps/vendor-web
npm run build:electron-cashier-renderer
# Outputs to apps/vendor-desktop/dist/renderer/
cd apps/vendor-desktop
npm 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 → build section

Main process logs:

  • Console output in terminal where npm run dev launched
  • macOS: ~/Library/Logs/Cashier/main.log
  • Windows: %APPDATA%\Cashier\logs\main.log

Renderer logs:

  • DevTools console (Cmd+Shift+I)

IPC tracing:

  • Add console.log in preload or main IPC handlers
  • Use ipcMain.on vs ipcMain.handle distinction (events vs request/response)

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

Disabled: nodeIntegration: false

Rationale: Renderer should not have Node.js require() - all privileged operations via IPC

Enabled in production: webSecurity: true

Dev mode: May be relaxed for CORS with local API (use VENDOR_DESKTOP_DEV=1)

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


SymptomLikely CauseSolution
App won’t startSingle-instance lockQuit existing instance, check Activity Monitor / Task Manager
Blank white screenRenderer failed to loadCheck dev server running (port 5180), inspect DevTools console
IPC call returns undefinedHandler not registered or typoVerify ipcMain.handle('channel-name', ...) in main.ts
Bridge won’t connectWrong wsUrl or firewallTest WebSocket with network:probe-ws, check backend logs
Print fails silentlyNo default printer or wrong deviceCall printer:get-printers, set default in OS
Scale not readingWrong protocol or device pathList USB devices with hardware:list-devices, verify path
Outbox orders not syncingNetwork down or backend errorCheck local-db:get-outbox logs, retry with backend online
Catalog staleSync not triggeredCall local-db:sync-catalog from renderer or reset local data
Auto-update stuckBad feed URL or networkCheck update feed JSON manually, verify SHA-512

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 reachability
const tcpProbe = await window.electron.invoke('network:probe-tcp', {
host: 'api.example.com',
port: 443,
timeoutMs: 5000
});
// Test WebSocket
const wsProbe = await window.electron.invoke('network:probe-ws', {
url: 'wss://api.example.com/api/v1/pos-bridge/ws',
timeoutMs: 5000
});

When to use: Corrupt database, wrong store, after testing

Flow:

  1. User → Settings → Local data → Reset
  2. Renderer calls local-db:reset with { storeId, backup: true }
  3. Main process:
    • Creates backup: pos-local-{storeId}-backup-{timestamp}.db
    • Deletes original DB
    • Re-initializes empty schema

Caution: Outbox orders are lost (ensure synced first)