Skip to main content

Desktop runtime

How FlowPOS Desktop is put together, and the handful of rules that explain most of its code.

Feature 054-desktop-runtime-shell. Spec: specs/054-desktop-runtime-shell/spec.md.

One process, two roles, an optional window​

There is one process. Its native runtime is always present; the POS window is a view over it.

  • pos — hosts the unchanged frontend-pwa in a WebView, over the same bridge contract the mobile host speaks.
  • print_agent — holds station claims, pulls work, routes it, and drives the outbox. It needs no window and no signed-in merchant.

A role does not change the process. Closing the POS window does not stop anything, and that is said to the merchant once per installation rather than every time — a message that appears on every close is one people learn to dismiss without reading. Quitting is explicit, and releases station claims before exiting so a planned shutdown does not look like a machine that died and make another device wait out the liveness cut-off.

The PWA never knows it is on desktop​

The web application is unmodified and does not branch on its host. It talks to window.__flowposBridge on desktop and window.ReactNativeWebView on mobile, and the shared bridge client is the only code that knows either name exists. Capabilities come from the handshake, over the contract, like every other fact about the host.

Two guards enforce this: no-host-branching.test.ts in the PWA, and no_pwa_internals.rs in the runtime.

Rust owns a job from enqueue to terminal state​

The queue, the executor and the retry boundary are all in Rust. The web application enqueues and observes; it never drives a job.

The retry boundary​

Nine executor states, of which four are terminal: done, done_with_warning, failed, abandoned. needs_attention is deliberately not terminal — it is work waiting for a person, and it leaves only by that person's decision.

The rule that matters: once the first byte of a payload has been handed to the transport, the runtime can no longer prove nothing reached the target. From that instant there is no automatic path back to a retryable state. Work interrupted there goes to needs_attention and waits for somebody who can walk to the printer and look.

Retrying such work always carries an explicit duplicate warning, and the acknowledgement is a type — DuplicateRiskAcknowledged has no constructor except one that takes the exact warning text displayed. A boolean argument would have satisfied the requirement's letter while the next caller passed true and nobody saw a warning.

The conformance corpus​

One corpus, two implementations: the reference adapter over node:sqlite, and the desktop adapter driving the real Rust binary as a child process. The child process is not an implementation detail — half the corpus is about surviving kill, and an in-process adapter can only simulate a process that stops existing mid-write.

packages/outbox-conformance/.

Two update paths, two keys​

These are genuinely separate, and conflating them is the mistake to avoid.

Web bundleNative release
What movesThe FlowPOS screensThe installed application
TriggerEvery frontend-pwa deployA tagged release
Needs an installerNoYes
Needs notarisationNoYes
Signing keyflowpos/prd_release_webflowpos/prd_release_shell
Takes effectNext start, or immediately from About → Check for updatesNext start

The keys are never held by one job. Shipping native code to a till is a strictly stronger privilege than shipping a web page: a bundle runs in a WebView that can only reach the bridge, while a native update replaces the executable. The web pipeline runs several times a day; compromising it must not authorise an executable.

scripts/check-signing-key-separation.mjs enforces this in CI — it fails if any job references both keys, if the bundle publisher can reach the release key, or if a private key is committed.

The two versions are always shown as two labelled values, never joined. An installation can be current on one path and months behind on the other, and that difference is usually the first thing support needs.

Bundle activation, in a fixed order​

retrieve → verify signature → verify digest → verify channel → verify requires → activate

Failure at any step prevents activation and leaves the current bundle running. Two of those failures look alike and are not:

  • A bad signature is an attack or a corruption. The bundle is discarded.
  • An unmet requirement is a machine that has not taken its native update yet. It is reported as a condition, not a failure, and the bundle is kept — the update that satisfies it may arrive tomorrow. A shop told "update failed" calls support; a shop told "this needs a newer FlowPOS" installs one.

Activation is a pointer swap, never an overwrite. An unattended retrieve stages the bundle and writes pending.json; the running page is left alone. The next start activates pending before the window opens. About → Check for updates is the merchant-confirmed path: it retrieves if needed, activates, and reloads the POS WebView so the new screens appear now. Native installers are a different button and a different key.

latest.json names a git SHA, not the About chip. The installer stores its UI as packaged, so those strings never match. Retrieve therefore also compares webVersion (the PWA package version in the published bundle) to the version this native build shipped: an older pointer is left alone. That is the difference between "a newer bundle is published" and "whatever SHA is in the bucket right now".

On the first start after an activation, a health check confirms the bundle loads, boots and completes its handshake within 8,000 ms. If it does not, the machine rolls back to the last known-good bundle and quarantines the failed version.

That check touches nothing outside the machine — no network, no sign-in, no backend, no printer. Not a preference: a check that could fail offline would turn one internet outage into a fleet of tills rolling back during service, for a reason nobody on site could see.

The bundle from the installer is never deleted. It is the floor for the case where every bundle a machine has ever held is bad, and the answer still has to be a machine somebody can open.

When Check for updates returns error​

updates.apply is the merchant-confirmed path in apps/desktop/src-tauri/src/bridge/handlers/updates.rs. Unattended retrieve (spawn_unattended) never reloads.

Pull variantUnattendedAbout → Check for updates
Currentno-opstatus: "up_to_date"
Readypending.json, activate on next startactivate now + WebView location.reload(); status: "applying"
NeedsNewerShelllog needs a newer application; bundle staged, not activatedstatus: "error" (same JSON as a retrieve fault)

NeedsNewerShell means the bundle verified (signature, digest, channel) and was written to disk, but requires.min_bridge_contract or a required capability exceeds this installation (apps/desktop/src-tauri/src/bundles/manifest.rs). The merchant needs a newer native desktop, not another web refresh. A shop told "update failed" calls support; the log line is the honest condition.

tauri dev short-circuits both paths with status: "development".

Printing symptoms after an update: Desktop printing troubleshooting.

Identity: one machine, one record per business​

An installation identifier is generated on first run — never at build or install time, so a shop that images one machine onto twelve tills gets twelve identifiers rather than one device fighting itself. It is stored in the OS credential store, or in an encrypted file where there is none, and which tier was used is recorded.

The identifier is read back after being written before it is trusted. Spike S5 hit a keychain that accepted writes and returned nothing; the symptom is a new device record on every launch, which reads as a busy fleet rather than a broken machine.

Both registration paths carry it: agent pairing and merchant sign-in. The backend holds at most one device record per (business, installation) and merges roles onto it.

The identity is scoped to the OS user profile, not the machine — both the credential store and the data directory are per-user. Two Windows accounts on one computer are two devices. See support.md, which leads with this because it is the thing most likely to look like a duplicate somebody should clean up.

Layering​

outbox, executor, agent and transport are headless: they depend on traits, never on Tauri, a window, or an event loop. bridge/ is the only module that calls Tauri APIs. tests/no_tauri_outside_bridge.rs fails the build if that slips.

This is not decoration. The conformance corpus has to drive the core with every real adapter replaced, and that is only possible if the boundary existed from the first commit rather than being recovered later.

Network access is confined to agent/; tests/offline_cold_start.rs asserts the start path performs no network I/O at all.

Nothing listens​

No socket is opened for management, configuration, pairing or claims. Spike S1 confirmed the custom app scheme gives a secure context with durable storage on both platforms, so the loopback serving model was never needed and the strongest form holds: nothing listens. tests/no_listener.rs fails on any bind in the source, and on any server crate appearing as a dependency.

Certificate rotation​

The desktop signing keys follow the same rotation runbook as the rest of FlowPOS — see the 051 certificate-rotation runbook, extended with a desktop section rather than duplicated here. Two things are specific to desktop:

  • Rotating the native release key invalidates the updater's trust in already-issued manifests. Publish a release signed with the new key before retiring the old one, or installations will refuse every update and simply stop updating, with nothing in any log to say why.
  • Rotating the web bundle key is safer but has the same shape: verification is compiled into the runtime, so a machine only trusts the key its installed release was built with.

Where things live​

PathWhat
src-tauri/src/outbox/The durable queue and its emitted SQL
src-tauri/src/executor/The state machine and the retry boundary
src-tauri/src/agent/Pairing, claims, work pull, routing
src-tauri/src/bundles/Bundle store, manifest, health check, rollback
src-tauri/src/updater/Native update verification and staging
src-tauri/src/identity/Installation identity, session tracking
src-tauri/src/bridge/The only module that calls Tauri
src-tauri/src/status/What the status window shows and does
src-tauri/src/i18n/The few strings the runtime owns
packages/outbox-conformance/The shared corpus and both adapters