Architecture
flexdeck is three programs and a contract between them.
| Piece | What it is | Built with |
|---|---|---|
| Desktop app | Owns the configuration and runs actions on the PC. Sits in the tray. | Rust, Tauri, SQLite |
| Android client | Draws the deck on a tablet and sends presses. | Kotlin, classic Views, minSdk 21 |
| Web portal | The editor. Configures the desktop from any browser, with no account. | React, Vite, Tailwind |
| Cloud relay | Serves the portal and forwards traffic for browsers that are not on the PC. Holds no accounts or credentials. | Rust |
flowchart LR
subgraph PC["Your PC"]
D["Desktop app<br/><sub>config · actions · plugins</sub>"]
B1["Browser on this PC"]
end
T1["Tablet"] -- "LAN · WSS, pinned TLS" --> D
T2["Tablet"] -- "LAN · WSS, pinned TLS" --> D
B1 -- "http://127.0.0.1" --> D
B2["Browser elsewhere"] -- HTTPS --> C["Cloud relay<br/><sub>flex-deck.com</sub>"]
D -- "outbound WSS" --> C
- On the same computer the portal talks straight to the desktop app on loopback, the way a keyboard vendor's web configurator talks to the keyboard on your desk. It connects by itself.
- From another device, the desktop app shows a code under Browsers → Connect a browser; typing it into the portal pairs that browser. The desktop issues and checks the browser's credential; the cloud only forwards traffic.
The desktop dials out to the cloud and nothing dials in. That is why there is no inbound port, no NAT rule and no port forward — and why the cloud never needs the desktop's local address. Credentials and what each one may do are in security.md.
The parity problem
The web configurator draws a live preview; the tablet draws the real thing. Two renderers, two languages, one layout. If they disagree by a single pixel, the preview lies — the user positions a button against a preview that does not match the device.
So the grid algorithm is written once as a spec and implemented three times, and the implementations are held together by golden fixtures:
contract/fixtures/grid/*.json— inputs (grid spec + widget spans)contract/fixtures/grid/*.expected.json— the exact integer rectangles every renderer must produce
The expectations are generated from the TypeScript reference implementation and then asserted by the Rust and Kotlin suites. Three CI jobs, one contract.
Rules the algorithm depends on (documented in packages/grid/src/index.ts):
- Integer arithmetic only — no floats, ever.
- The gap borders both outer edges and sits between cells:
ncells haven + 1gaps. - Remainder pixels go to the first
remaindercells, left to right. - A spanning widget covers the gaps inside it.
Key decisions
| Decision | Choice | Why |
|---|---|---|
| Type contract | proto3, compiled to TS/Rust/Kotlin |
One schema; proto3's JSON mapping is specified, so field naming stays consistent across languages for free |
| Wire format | JSON + RFC 6902 JSON-Patch | Keeps the PRD's debuggable JSON wire and cheap delta sync; the .proto supplies only the types |
| Grid | Integer cells, explicit per-profile layouts | Identical rects are computable in every language; no free-form CSS to drift |
| Android | Native Kotlin, classic Views, minSdk 21 | Old tablets have outdated WebViews and little RAM |
| Grid module | Pure Kotlin/JVM, not Android library | The maths is verifiable in CI without an Android SDK |
Repository layout
contract/ Single source of truth
proto/ proto3 schema shared by all three renderers
fixtures/ Golden fixtures — grid, faces, plugin protocol
apps/
agent/ The headless agent binary (`flexdeck`)
desktop/ The desktop app: a Tauri window over the same agent
portal/ The web editor (Vite + React), served by the cloud
crates/
flexdeck-actions/ Action engine: hotkey parsing, desktop execution
flexdeck-automation/ Automations: a small, bounded, visual language
flexdeck-cloud/ Cloud relay: portal serving, tunnel, browser-code routing
flexdeck-expr/ Templates and expressions: labels, rules, recipe values
flexdeck-grid/ Grid algorithm + parity test (Rust)
flexdeck-pairing/ Pairing: TLS identity, PIN/QR windows, device tokens
flexdeck-plugin/ Plugin registry and the built-in plugins
flexdeck-plugin-abi/ The plugin contract: manifest, messages, plugin.json
flexdeck-plugin-host/ Runs an installed plugin as a supervised process
flexdeck-plugin-sdk/ Write a native plugin in Rust
flexdeck-protocol/ Generated proto3 types + proto3 JSON mapping
flexdeck-server/ Agent server: sync core, REST API, WebSocket hub
flexdeck-store/ SQLite config store: revision log, undo/redo, patch diff
flexdeck-tunnel/ Outbound tunnel: frame codec + request correlation
flexdeck-vars/ Typed, namespaced live variables
packages/
grid/ Grid algorithm (TypeScript reference implementation)
face/ Key faces: which face a key shows and where things fall
ui/ Shared interface kit, motion presets, ASCII engine, the mark
tokens/ Design tokens shared by the portal and the desktop window
plugin-sdk/ Write plugins in TypeScript: SDK, test harness, packer
plugin-sdk-python/ Write plugins in Python (published as `flexdeck-plugin`)
create-flexdeck-plugin/ `npm create flexdeck-plugin`
plugins/ First-party plugins: OBS, Discord, Spotify, Streamer.bot, …
recipes/ Plugins without code: weather, calendar, crypto, Home Assistant
android/
app/ The tablet client: pairing, deck renderer, TLS pinning
core/grid/ Grid algorithm + parity test (Kotlin, pure JVM)
core/client/ Wire frames, JSON Patch, session, gestures (pure JVM)
scripts/ Dev launchers, icon and artwork generators
docs/ You are here
Client-side proto codegen is still absent: only the Rust side is generated (see
crates/flexdeck-protocol/build.rs). buf is not installed, so contract/buf.gen.yaml
is not the working codegen path.