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: falsecontextIsolation: truesandbox: truewebSecurity: trueallowRunningInsecureContent: false- Strict Content-Security-Policy on the renderer; no inline scripts; no remote scripts; assets served from
file://or the bundled dev server only. contextBridgeexposes a narrow, typed API surface (window.melmastoon.*); no genericipcRendereraccess.- Every IPC handler in main validates input with Zod; refuses on schema mismatch.
- Auto-updates are signed with a code-signing certificate;
electron-updaterverifies 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-serviceon 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
| Concern | Electron | Tauri | Decision driver |
|---|---|---|---|
| Lock vendor SDKs (TTLock, Salto, Assa Abloy) | Ship Node bindings; first-class | Require Rust crates (often community-maintained, lagging) or FFI | Electron wins — vendor ecosystem is the constraint |
| Embedded SQLite | better-sqlite3 is the de-facto best | rusqlite works but lacks the synchronous-on-Node ergonomics our renderer-via-IPC pattern depends on | Electron wins on developer ergonomics |
| Edge AI inference | ONNX Runtime Node is mature, documented, and matches our cloud ai-orchestrator-service runtime model | tract / ort-rs exists but is a smaller ecosystem with fewer model recipes | Electron wins on model availability + cloud parity |
| Signed installers + auto-update | electron-builder + electron-updater are battle-tested for line-of-business apps; one config covers Win/Mac/Linux | tauri-bundler + tauri-plugin-updater work but are younger; signed-update story for Windows MSI is rougher | Electron wins on hotel-IT deployability |
| Hiring profile (target markets) | TS / Node — same as the rest of our stack; abundant | Rust — scarce in Afghanistan, Tajikistan, Iran, Pakistan | Electron wins on hiring math |
| Binary size | ~140 MB | ~10 MB | Tauri wins — but install size is not our constraint; network reliability is |
| RAM at idle | Higher (Chromium) | Lower (system webview) | Tauri wins — but our target hardware (modern Windows / Mac / Linux laptops) handles it; this is not a blocker |
| OS keychain | keytar (Node native module) | Built-in API | Both adequate |
| Receipt printer / serial / USB | serialport, node-hid (Node native) | Possible but more friction | Electron wins on peripheral ecosystem |
| Code reuse with rest of platform | High — same TS, same domain, same shared libraries | Lower — Rust shell + JS renderer means two languages to maintain | Electron 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-ofwith 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-servicematerializes the realKeyCredentialand 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
| Alternative | Why rejected |
|---|---|
| Tauri | Lock 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: true | Renderer 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 queue | Defeats 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 +
contextBridgeposture matches the modern Electron security baseline, not the legacynodeIntegration: trueposture. - Edge AI inference is a Node-side concern, mirroring the cloud
ai-orchestrator-servicemodel.
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, narrowcontextBridge, and signed updates. - Native modules (
better-sqlite3,keytar,node-hid,serialport,onnxruntime-node) require per-platform compilation; we useelectron-builderwith prebuilt-binary mirrors and CI matrix builds.
Compliance
- Every Electron build must run
electron-builderwith signing enabled; unsigned builds fail CI. - Every renderer window must be created with
nodeIntegration: false,contextIsolation: true,sandbox: true. CI lint enforces. - The
contextBridgeAPI 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-updaterconfiguration 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|pushcontract; contract tests in CI.