Skip to main content

ADR 0003: Electron offline-first desktop (Ghasi Melmastoon)

Status

Accepted — 2026-04-22.

Context

The Ghasi Melmastoon backoffice is a staff-installed line-of-business application for hotels in markets where the public internet is routinely unreliable (1–12 hour outages are common, throttled bandwidth is the baseline, and mobile data is expensive). The application must run the property's daily operations — reservations, check-in / check-out, housekeeping, maintenance, billing drafts, key issuance — without a working internet connection, and reconcile cleanly when the network returns.

It must also:

  • Talk to physical lock hardware (TTLock BLE, Salto XS4, Assa Abloy Vostio, generic Wiegand/RFID encoders over USB or serial), payment terminals, receipt printers, ID scanners, and barcode readers — i.e. real OS-level peripherals.
  • Run on-device AI inference for housekeeping ordering, anomaly heuristics, demand smoothing, and image quality scoring, without round-tripping to the cloud.
  • Ship signed installers and signed auto-updates for Windows / macOS / Linux that hotel IT can deploy with no extra toolchain.
  • Use OS keychain for secrets (refresh token, device-binding key, SQLite encryption key derivation material).
  • Be buildable, debuggable, and hireable-for by a TS-first team in our target geographies.

Two production-grade desktop frameworks are realistic candidates: Electron and Tauri. This ADR records the decision to standardize on Electron and freezes the process model and security posture so every future iteration of the desktop app is built the same way.

Decision

1. Stack

  • Electron (Node 20 main process + Chromium renderer)
  • Vite (renderer build tooling — fast HMR, ESM)
  • React (renderer UI)
  • better-sqlite3 (synchronous, fast, embedded SQLite — local store)
  • ONNX Runtime Node (offline AI inference, executed in main process)
  • electron-builder (signed installers — .exe / .msi, .dmg, AppImage / .deb)
  • electron-updater (signed auto-updates with rollback)
  • keytar (OS keychain — Credential Manager / Keychain / libsecret)
  • TypeScript end-to-end (main, preload, renderer, shared)

2. Process model

┌───────────────────────────────────────────────────────────────────────┐
│ Electron Main (Node 20) │
│ │
│ ┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Lock SDKs │ │ AI inference │ │ Sync worker │ │
│ │ (TTLock, Salto, │ │ (ONNX Runtime │ │ (sync-service │ │
│ │ Assa Abloy, │ │ Node, dedicated │ │ /sync/v1/pull| │ │
│ │ generic │ │ worker thread) │ │ push) │ │
│ │ Wiegand) │ └──────────────────┘ └──────────────────┘ │
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ better-sqlite3 │ │ keytar (OS │ │ electron-updater │ │
│ │ (local store + │ │ keychain) │ │ (signed auto- │ │
│ │ outbox) │ │ │ │ update) │ │
│ └─────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌──────────────── IPC over contextBridge only ─────────────────┐ │
│ └──────────────────────────────────────────────────────────────┘ │
└────────────────────────┬──────────────────────────────────────────────┘


┌───────────────────────────────────────────────────────────────────────┐
│ Preload (typed contextBridge) │
│ │
│ exposes: window.melmastoon = { │
│ auth: { signIn, signOut, currentUser }, │
│ db: { query, mutate }, // domain-shaped, not SQL │
│ sync: { status, pullNow, pushNow, onProgress }, │
│ locks:{ issueKey, revokeKey, listAdapters, onEvent }, │
│ ai: { infer, listModels, onLocalEvent }, │
│ fs: { exportInvoice, importCsv }, // narrowly typed paths only │
│ printer: { print, listPrinters }, │
│ telemetry: { event, error } │
│ } │
└────────────────────────┬──────────────────────────────────────────────┘


┌───────────────────────────────────────────────────────────────────────┐
│ Renderer (Chromium, React, TypeScript) │
│ │
│ - nodeIntegration: false │
│ - contextIsolation: true │
│ - sandbox: true │
│ - no Node API access; no `require`; no `child_process` │
│ - ONLY talks to main via `window.melmastoon.*` │
│ - mirror of web architecture: app/ → hooks+services → domain → adp │
└───────────────────────────────────────────────────────────────────────┘

3. Security posture (non-negotiable)

  • nodeIntegration: false
  • contextIsolation: true
  • sandbox: true
  • webSecurity: true
  • allowRunningInsecureContent: false
  • Strict Content-Security-Policy on the renderer; no inline scripts; no remote scripts; assets served from file:// or the bundled dev server only.
  • contextBridge exposes a narrow, typed API surface (window.melmastoon.*); no generic ipcRenderer access.
  • Every IPC handler in main validates input with Zod; refuses on schema mismatch.
  • Auto-updates are signed with a code-signing certificate; electron-updater verifies signature before applying.
  • Installer is signed (Windows EV cert, Apple Developer ID, Linux GPG signature on the AppImage / .deb).
  • Refresh token + device key live in OS keychain via keytar; never in plaintext on disk; never in renderer process memory.
  • SQLite database file is encrypted with a key derived (HKDF) from the device key + a salt held in the OS keychain.
  • Lock-vendor credentials live behind lock-integration-service on the cloud side; the desktop never holds long-lived vendor secrets — only short-lived per-session tokens issued by the cloud.

4. Why Electron over Tauri

ConcernElectronTauriDecision driver
Lock vendor SDKs (TTLock, Salto, Assa Abloy)Ship Node bindings; first-classRequire Rust crates (often community-maintained, lagging) or FFIElectron wins — vendor ecosystem is the constraint
Embedded SQLitebetter-sqlite3 is the de-facto bestrusqlite works but lacks the synchronous-on-Node ergonomics our renderer-via-IPC pattern depends onElectron wins on developer ergonomics
Edge AI inferenceONNX Runtime Node is mature, documented, and matches our cloud ai-orchestrator-service runtime modeltract / ort-rs exists but is a smaller ecosystem with fewer model recipesElectron wins on model availability + cloud parity
Signed installers + auto-updateelectron-builder + electron-updater are battle-tested for line-of-business apps; one config covers Win/Mac/Linuxtauri-bundler + tauri-plugin-updater work but are younger; signed-update story for Windows MSI is rougherElectron wins on hotel-IT deployability
Hiring profile (target markets)TS / Node — same as the rest of our stack; abundantRust — scarce in Afghanistan, Tajikistan, Iran, PakistanElectron wins on hiring math
Binary size~140 MB~10 MBTauri wins — but install size is not our constraint; network reliability is
RAM at idleHigher (Chromium)Lower (system webview)Tauri wins — but our target hardware (modern Windows / Mac / Linux laptops) handles it; this is not a blocker
OS keychainkeytar (Node native module)Built-in APIBoth adequate
Receipt printer / serial / USBserialport, node-hid (Node native)Possible but more frictionElectron wins on peripheral ecosystem
Code reuse with rest of platformHigh — same TS, same domain, same shared librariesLower — Rust shell + JS renderer means two languages to maintainElectron wins

The Tauri wins (binary size, idle RAM) are real but not blocking for a staff-installed desktop application. The Electron wins (vendor ecosystem, hiring, installer maturity, code reuse) are blocking-or-near-blocking for our market. The decision is not close for our context; it could easily be reversed for a different product.

5. Why offline-first (not "offline-tolerant")

Hotels in our target markets routinely face 1–12 hour internet outages. The operations that cannot stop during an outage:

  • Check-in (guest is at the desk; no software answer is unacceptable)
  • Key issuance (guest must get to their room)
  • Folio updates (charges accrue regardless of connectivity)
  • Housekeeping status flips (rooms must turn over)
  • Walk-in booking capture (revenue cannot wait for the internet)

Therefore:

  • The local SQLite is the source of truth during an outage. The cloud is the source of truth otherwise. Reconciliation is via the canonical sync protocol (/sync/v1/pull|push) with per-aggregate conflict policy (see 02 Enterprise Architecture §8).
  • All mutations are idempotent (clientMutationId + Idempotency-Key); resending a mutation after the network returns is safe.
  • Money and inventory never use last-write-wins — folio entries are append-only, inventory is server-authoritative on reconnect, room status uses max-of with a worst-status-wins priority.
  • The desktop maintains its own outbox in SQLite; the sync worker drains it FIFO when online.
  • Lock issuance queues offline (a "pending key" record); on reconnect, lock-integration-service materializes the real KeyCredential and the desktop re-issues to the encoder.

6. AI inference placement

ONNX Runtime Node runs in a dedicated worker thread inside the main process (not in the renderer). The renderer requests inference via window.melmastoon.ai.infer({...}). Models are signed; signature is verified at load; tampered models refuse to load. Edge inference still emits ai.inference.local.completed.v1 to the local outbox; on next sync the event is replayed for audit.

Alternatives Considered

AlternativeWhy rejected
TauriLock vendor SDKs in Node, not Rust; hiring profile in target geographies favors TS; electron-builder + electron-updater is more mature for line-of-business apps; binary-size advantage doesn't matter for staff-installed software.
Web app (PWA only, no desktop)Insufficient peripheral access (USB encoders, serial Wiegand, receipt printers); no signed installer story for hotel IT; offline limits in browsers (storage quotas, background sync flakiness). PWA is fine for guest-facing surfaces, not for backoffice.
React Native for desktop (RN-Windows / RN-macOS)Linux gap; smaller ecosystem; weaker tooling than Electron; same security model surface area to defend.
Native per-OS apps (.NET on Windows, Swift on macOS)Triples maintenance; defeats the TS-everywhere choice from ADR-0001; no shared domain library.
Electron with nodeIntegration: trueRenderer XSS becomes RCE; this is a known footgun and is non-negotiable. We ship sandboxed renderer with contextBridge only.
No local DB; talk to cloud over a queueDefeats offline-first. The whole point is that operations work without the cloud.

Consequences

Positive

  • The desktop app shares language (TS), domain library, design tokens, i18n bundle, and AI provenance contract with the rest of the platform.
  • Vendor SDK availability for locks and peripherals is a solved problem rather than an open question.
  • Hotel IT can deploy signed installers without learning new tooling.
  • The sandboxed-renderer + contextBridge posture matches the modern Electron security baseline, not the legacy nodeIntegration: true posture.
  • Edge AI inference is a Node-side concern, mirroring the cloud ai-orchestrator-service model.

Negative

  • Larger binary (~140 MB) and higher idle RAM than Tauri. Acceptable for our user profile (installed on staff workstations).
  • Electron has a history of high-profile security incidents in apps that did not lock down the renderer. We mitigate by enforcing nodeIntegration: false, contextIsolation: true, sandbox, strict CSP, narrow contextBridge, and signed updates.
  • Native modules (better-sqlite3, keytar, node-hid, serialport, onnxruntime-node) require per-platform compilation; we use electron-builder with prebuilt-binary mirrors and CI matrix builds.

Compliance

  • Every Electron build must run electron-builder with signing enabled; unsigned builds fail CI.
  • Every renderer window must be created with nodeIntegration: false, contextIsolation: true, sandbox: true. CI lint enforces.
  • The contextBridge API surface (window.melmastoon.*) must be declared in a single typed file; additions require code review with security checklist.
  • Every IPC handler in main must validate input with Zod.
  • Auto-updates must be signed and verified before apply; electron-updater configuration enforces.
  • Refresh tokens and device keys must be stored via keytar; no plaintext on disk; CI grep enforces against common anti-patterns.
  • SQLite file must be encrypted with a key derived from the device key.
  • No vendor SDK for AI may be bundled in the desktop app except ONNX Runtime Node; CI dependency check enforces.
  • The desktop sync worker must implement the canonical /sync/v1/pull|push contract; contract tests in CI.

References