Compare commits

...

10 Commits

Author SHA1 Message Date
e8b968bcd1 add README and MIT license 2026-06-19 03:28:41 +02:00
fe59b68c37 theme config, layout preview, container alignment
Server: ThemeConfig entity + CRUD (GET/PUT /theme), SQLite persistence,
ThemeUpdate broadcast to ESP32 on save and initial connect.
Client: render engine uses theme colors, full-screen redraw on theme change.
SPA: theme page with color pickers + presets, layout preview with TS port
of layout engine, justify/align controls on containers.
DisplayHint refactored to struct (kind + h_align + v_align).
2026-06-19 03:26:18 +02:00
81a4167382 new rendering engine 2026-06-19 02:55:33 +02:00
0a90d6a5d7 logo update
Co-authored-by: Copilot <copilot@github.com>
2026-06-19 02:13:36 +02:00
adda731dc6 add auth system: users, login, JWT, protected routes
Domain: User entity, AuthPort/PasswordHashPort/SecretStore ports.
Adapters: auth (argon2 hashing, JWT tokens), secret-store (env-based),
config-sqlite user repository, http-api auth routes + extractors.
Application: auth_service. SPA: login page, auth client, protected router.
2026-06-19 01:39:42 +02:00
4139330234 esp32: wifi provisioning via AP captive portal
Replace compile-time env!() wifi/server config with NVS-based
runtime provisioning. Boot checks NVS — if no config, starts
AP mode (KFrame-Setup) with DNS responder + HTTP config form.
WiFi failure clears config and reboots into setup mode.
2026-06-19 01:38:48 +02:00
1d7b5324d6 per-source polling, initial client state, webhook, preview, client tracking
- per-source poll intervals: spawn task per source with own interval,
  manager re-checks sources every 30s for add/remove
- initial screen update on TCP connect: send layout + widget states
- client tracking: ClientRegistry port, GET /api/clients, dashboard list
- webhook adapter: POST /api/webhook/{source_id} feeds data into projection
- widget preview: GET /api/widgets/{id}/preview returns current state
- serve SPA from Axum: ServeDir + index.html fallback via KFRAME_SPA_DIR
- layout builder delete confirmation with AlertDialog
- form validation: required fields disable save button
- guide page at /guide
- fix architecture: ClientDto to api-types, ClientRegistry + WidgetStateReader
  ports in domain, DataProjection has internal Mutex, no adapter cross-deps
- ESP32: full screen clear on layout change (stale pixel fix)
2026-06-19 00:42:31 +02:00
26ebfad3a2 add SPA config UI, wire media/rss adapters, event-driven layout push
- React SPA: dashboard, data sources CRUD, widgets CRUD, layout builder,
  presets. TanStack Router + Query, shadcn/ui, Vite proxy to :3000
- wire media + rss adapters into polling loop, remove xtb source type
- media adapter: read username/password from headers, proper subsonic auth
- event handler: subscribe to LayoutChanged, push screen update to clients
- fix clippy warnings across workspace (Default impls, collapsible ifs,
  redundant closures, is_none_or, unused imports)
2026-06-19 00:12:42 +02:00
21c08911df add tracing, env config, dotenvy
bootstrap: tracing-subscriber with RUST_LOG env filter, ServerConfig
from env vars (KFRAME_DATABASE_URL, KFRAME_TCP_ADDR, etc.), dotenvy
for .env file loading. Replaced all println with tracing macros.

tcp-server: replaced println with tracing::info/warn.

Added .env.example and .gitignore for db files.
2026-06-18 23:14:43 +02:00
15b75d860c rewire bootstrap with SQLite, HTTP API, and real polling
bootstrap: SQLite config, HTTP API on :3000, TCP on :2699, poll loops.
http-api: added serve() so bootstrap doesn't depend on axum directly.
polling: reads data sources from config, polls via http-json adapter,
pushes changed widgets to connected clients.

configure via API, e.g.:
  curl -X POST localhost:3000/api/data-sources -H 'Content-Type: application/json' -d '{...}'
  curl -X PUT localhost:3000/api/layout -H 'Content-Type: application/json' -d '{...}'
2026-06-18 23:12:05 +02:00
238 changed files with 18108 additions and 1098 deletions

18
.env.example Normal file
View File

@@ -0,0 +1,18 @@
# K-Frame Server Configuration
KFRAME_DATABASE_URL=sqlite:kframe.db?mode=rwc
KFRAME_TCP_ADDR=0.0.0.0:2699
KFRAME_HTTP_ADDR=0.0.0.0:3000
KFRAME_POLL_INTERVAL_SECS=5
# Auth (required)
JWT_SECRET=change-me-to-a-random-secret
JWT_TTL_SECONDS=3600
# Encryption at rest (required, generate with: openssl rand -hex 32)
KFRAME_ENCRYPTION_KEY=change-me-generate-with-openssl-rand-hex-32
# SPA static files (optional, omit for dev mode with Vite proxy)
# KFRAME_SPA_DIR=spa/dist
# Logging (tracing-subscriber)
RUST_LOG=info,sqlx=warn

4
.gitignore vendored
View File

@@ -1 +1,5 @@
target/ target/
*.db
*.db-shm
*.db-wal
.env

View File

@@ -18,7 +18,7 @@ Value objects have no identity. They are defined entirely by their content and a
- **Layout** — the single active layout tree. A recursive structure of LayoutNodes. Always singular — clients display exactly one layout at a time. Replaced wholesale when user reconfigures via the web UI. - **Layout** — the single active layout tree. A recursive structure of LayoutNodes. Always singular — clients display exactly one layout at a time. Replaced wholesale when user reconfigures via the web UI.
- **LayoutNode** — a node in the layout tree. Either a Container (row or column with ordered children) or a Leaf (references a Widget by ID). Each child in a Container has a sizing mode: Fixed(pixels) or Flex(weight). Layout engine sums fixed sizes, distributes remaining space among flex children by integer weight ratio. All integer math, no floats. Containers have an optional `gap: u8` (uniform spacing between children in pixels) and an optional `padding: u8` (uniform inset on all sides, typically used on root container to keep content off screen edges). - **LayoutNode** — a node in the layout tree. Either a Container (row or column with ordered children) or a Leaf (references a Widget by ID). Each child in a Container has a sizing mode: Fixed(pixels) or Flex(weight). Layout engine sums fixed sizes, distributes remaining space among flex children by integer weight ratio. All integer math, no floats. Containers have an optional `gap: u8` (uniform spacing between children in pixels), an optional `padding: u8` (uniform inset on all sides), a `JustifyContent` (main axis distribution: Start, Center, End, SpaceBetween, SpaceEvenly), and an `AlignItems` (cross axis alignment: Start, Center, End, Stretch). Both default to Start.
- **KeyMapping** — a rule inside WidgetConfig that extracts a value from a DataSource's raw response using a JSON path expression and maps it to a named key in the WidgetState. E.g. `"$.main.temp" → "value"`. Decouples widget data shape from API response shape. Keeps adapters dumb — they return raw responses, WidgetConfig defines extraction. - **KeyMapping** — a rule inside WidgetConfig that extracts a value from a DataSource's raw response using a JSON path expression and maps it to a named key in the WidgetState. E.g. `"$.main.temp" → "value"`. Decouples widget data shape from API response shape. Keeps adapters dumb — they return raw responses, WidgetConfig defines extraction.
@@ -42,13 +42,27 @@ The client has its own thin domain — hexagonal, chip-agnostic, display-agnosti
- **BoundingBox** — a rectangular region on screen (x, y, width, height) computed by the layout engine for each node. - **BoundingBox** — a rectangular region on screen (x, y, width, height) computed by the layout engine for each node.
- **WidgetView** — the rendered projection of a WidgetState. Contains resolved display data (text strings, icon references) within a BoundingBox. The client domain decides overflow behavior, truncation, and marquee. - **WidgetView** — the rendered projection of a WidgetState. Contains resolved display data (text spans with colors), scroll state, and computed positions within a BoundingBox. The client domain formats raw WidgetState data according to DisplayHint rules (e.g. KeyValue renders keys in secondary color), applies text alignment (HAlign, VAlign), detects overflow, and manages bounce-scroll animation. Scroll triggers only on overflow — static text stays static. Bounce speed auto-derives from overflow amount for consistent reading pace.
- **Layout Engine** — pure domain logic. Given a LayoutNode tree and screen dimensions, computes BoundingBoxes for all nodes. Containers split space among children (row = horizontal, column = vertical). Leaves get their final bounds. - **Layout Engine** — pure domain logic. Given a LayoutNode tree and screen dimensions, computes BoundingBoxes for all nodes. Containers split space among children (row = horizontal, column = vertical), applying JustifyContent and AlignItems for positioning. Leaves get their final bounds.
- **Render Engine** — pure domain logic. Given a WidgetView and FontMetrics, parses inline color markup, performs word-based line wrapping (character fallback for long words), computes alignment offsets, applies scroll position, and emits positioned text spans for the DisplayPort to draw. Owns all text intelligence — the DisplayPort is a thin pixel-pusher.
- **FontMetrics** — character dimensions for each font size (Small, Large), injected into the domain at init by the adapter. Enables pure-math text measurement (monospace: width = char_count × char_width) without hardware coupling.
- **Color** — domain-owned RGB color value (u8, u8, u8). Used on all domain and port boundaries. Adapters convert to native format (e.g. Rgb565 on ESP32) at the last mile.
- **ThemeConfig** — a set of five named Colors that define the visual appearance of the client: `primary`, `secondary`, `accent`, `text` (default for all unmarked content), and `background` (used for all fills/clears). Pushed from server to client independently of layout via a ThemeUpdate message. Client stores current theme and falls back to sensible defaults if no theme has been received. Configured from the web UI.
- **DisplayPort** — the client's rendering abstraction. A thin pixel-pusher with three methods: `draw_text_span(text, x, y, color, font_size)`, `fill_rect(bounds, color)`, `flush()`. All text intelligence (wrapping, alignment, scrolling, markup parsing) lives in the domain's render engine, not in the port. Adapters convert domain Color to native format and delegate to hardware.
- **FontSize** — enum selecting which bitmap font to use: Small (body text) or Large (icons). Passed with each draw_text_span call. The domain picks the font; the adapter maps it to a concrete bitmap font.
## Shared Concepts ## Shared Concepts
- **DisplayHint** — a domain enum on WidgetConfig indicating how the client should render the widget. Closed set: IconValue, TextBlock, KeyValue, etc. Client handles each variant explicitly, with a fallback to plain text for unknown/unsupported hints. Gives type safety and validation on both sides while remaining forward-compatible. Lives in the protocol crate. - **DisplayHint** — a domain enum on WidgetConfig indicating how the client should render the widget. Closed set: IconValue, TextBlock, KeyValue, etc. Each variant is a rendering recipe — the client-domain's render engine formats raw WidgetState data into styled text spans according to the variant's rules (e.g. KeyValue renders keys in secondary color, values in text color). Carries content-level alignment: HAlign (Left, Center, Right) and VAlign (Top, Middle, Bottom). Client handles each variant explicitly with exhaustive match; fallback to plain text for unknown hints. Lives in the protocol crate.
- **Inline Color Markup** — lightweight syntax embedded in Value strings for coloring text spans. Syntax: `{#RRGGBB}text{/}` for hex colors, `{primary}text{/}`, `{secondary}text{/}`, `{accent}text{/}` for theme colors. `{/}` resets to the theme's `text` color. Parsed by the client-domain render engine. No bold/italic — hardware constraint on current bitmap fonts.
- **Value** — domain-owned value type representing structured data. An enum: String, Number, Bool, Array, Object, Null. Adapters convert their native formats (JSON, XML, etc.) into Value. KeyMapping and WidgetState operate on Value exclusively. Zero coupling to serialization libraries. Lives in the protocol crate. - **Value** — domain-owned value type representing structured data. An enum: String, Number, Bool, Array, Object, Null. Adapters convert their native formats (JSON, XML, etc.) into Value. KeyMapping and WidgetState operate on Value exclusively. Zero coupling to serialization libraries. Lives in the protocol crate.

414
Cargo.lock generated
View File

@@ -2,12 +2,62 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.21" version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]] [[package]]
name = "api-types" name = "api-types"
version = "0.1.0" version = "0.1.0"
@@ -25,6 +75,18 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]] [[package]]
name = "atoi" name = "atoi"
version = "2.0.0" version = "2.0.0"
@@ -131,6 +193,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -144,12 +215,21 @@ dependencies = [
name = "bootstrap" name = "bootstrap"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"application", "application",
"config-memory", "config-sqlite",
"domain", "domain",
"protocol", "dotenvy",
"http-api",
"http-json",
"kframe-auth",
"media-adapter",
"rss-adapter",
"secret-store",
"tcp-server", "tcp-server",
"tokio", "tokio",
"tracing",
"tracing-subscriber",
] ]
[[package]] [[package]]
@@ -186,6 +266,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cipher"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
]
[[package]] [[package]]
name = "client-application" name = "client-application"
version = "0.1.0" version = "0.1.0"
@@ -330,9 +420,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array", "generic-array",
"rand_core",
"typenum", "typenum",
] ]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@@ -344,6 +444,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -599,8 +705,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi", "wasi",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -614,6 +722,16 @@ dependencies = [
"r-efi", "r-efi",
] ]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.15" version = "0.4.15"
@@ -760,6 +878,12 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
@@ -963,6 +1087,15 @@ dependencies = [
"hashbrown 0.17.1", "hashbrown 0.17.1",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@@ -986,6 +1119,32 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "kframe-auth"
version = "0.1.0"
dependencies = [
"argon2",
"domain",
"jsonwebtoken",
"rand_core",
"serde",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -1057,6 +1216,15 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]] [[package]]
name = "matchit" name = "matchit"
version = "0.8.4" version = "0.8.4"
@@ -1073,12 +1241,20 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "md5"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771"
[[package]] [[package]]
name = "media-adapter" name = "media-adapter"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"domain", "domain",
"fastrand",
"md5",
"reqwest", "reqwest",
"serde_json", "serde_json",
"thiserror", "thiserror",
@@ -1097,6 +1273,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.1" version = "1.2.1"
@@ -1125,6 +1311,25 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-bigint-dig" name = "num-bigint-dig"
version = "0.8.6" version = "0.8.6"
@@ -1141,6 +1346,12 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]] [[package]]
name = "num-integer" name = "num-integer"
version = "0.1.46" version = "0.1.46"
@@ -1177,6 +1388,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.81" version = "0.10.81"
@@ -1249,6 +1466,27 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core",
"subtle",
]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"serde_core",
]
[[package]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@@ -1303,6 +1541,18 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]] [[package]]
name = "postcard" name = "postcard"
version = "1.1.3" version = "1.1.3"
@@ -1324,6 +1574,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@@ -1353,9 +1609,9 @@ dependencies = [
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.37.5" version = "0.40.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f"
dependencies = [ dependencies = [
"memchr", "memchr",
"serde", "serde",
@@ -1424,6 +1680,23 @@ dependencies = [
"bitflags", "bitflags",
] ]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.28" version = "0.12.28"
@@ -1585,6 +1858,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "secret-store"
version = "0.1.0"
dependencies = [
"aes-gcm",
"base64",
"domain",
"hex",
"rand_core",
]
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "3.7.0" version = "3.7.0"
@@ -1696,6 +1980,15 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "2.0.1" version = "2.0.1"
@@ -1712,6 +2005,18 @@ dependencies = [
"rand_core", "rand_core",
] ]
[[package]]
name = "simple_asn1"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-traits",
"thiserror",
"time",
]
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -2039,6 +2344,7 @@ dependencies = [
"protocol", "protocol",
"thiserror", "thiserror",
"tokio", "tokio",
"tracing",
] ]
[[package]] [[package]]
@@ -2074,6 +2380,45 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d"
dependencies = [
"num-conv",
"time-core",
]
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.3" version = "0.8.3"
@@ -2193,10 +2538,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"tokio",
"tokio-util",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
@@ -2245,6 +2599,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
] ]
[[package]] [[package]]
@@ -2259,6 +2643,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-bidi" name = "unicode-bidi"
version = "0.3.18" version = "0.3.18"
@@ -2286,6 +2676,16 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"subtle",
]
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@@ -2310,6 +2710,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"

View File

@@ -14,6 +14,8 @@ members = [
"crates/adapters/http-json", "crates/adapters/http-json",
"crates/adapters/rss", "crates/adapters/rss",
"crates/adapters/media", "crates/adapters/media",
"crates/adapters/auth",
"crates/adapters/secret-store",
"crates/api-types", "crates/api-types",
"crates/bootstrap", "crates/bootstrap",
"crates/client-desktop", "crates/client-desktop",
@@ -34,12 +36,20 @@ tcp-server = { path = "crates/adapters/tcp-server" }
tcp-client = { path = "crates/adapters/tcp-client" } tcp-client = { path = "crates/adapters/tcp-client" }
display-terminal = { path = "crates/adapters/display-terminal" } display-terminal = { path = "crates/adapters/display-terminal" }
config-sqlite = { path = "crates/adapters/config-sqlite" } config-sqlite = { path = "crates/adapters/config-sqlite" }
http-json = { path = "crates/adapters/http-json" }
http-api = { path = "crates/adapters/http-api" } http-api = { path = "crates/adapters/http-api" }
media-adapter = { path = "crates/adapters/media" }
rss-adapter = { path = "crates/adapters/rss" }
kframe-auth = { path = "crates/adapters/auth" }
secret-store = { path = "crates/adapters/secret-store" }
axum = { version = "0.8", features = ["macros"] } axum = { version = "0.8", features = ["macros"] }
tower-http = { version = "0.6", features = ["cors"] } tower-http = { version = "0.6", features = ["cors", "fs"] }
api-types = { path = "crates/api-types" } api-types = { path = "crates/api-types" }
thiserror = "2.0" thiserror = "2.0"
anyhow = "1.0" anyhow = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"
serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] } serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] }
serde_json = "1.0" serde_json = "1.0"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite"] }

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gabriel
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -35,19 +35,16 @@ server:
desktop: desktop:
cargo run --bin client-desktop cargo run --bin client-desktop
# Build ESP32 firmware. Requires env: KFRAME_WIFI_SSID, KFRAME_WIFI_PASS, KFRAME_SERVER_ADDR # Build ESP32 firmware.
esp-build: esp-build:
@test -n "$(KFRAME_WIFI_SSID)" || (echo "Set KFRAME_WIFI_SSID, KFRAME_WIFI_PASS, KFRAME_SERVER_ADDR" && exit 1)
cd crates/client-esp32 && cargo build --release cd crates/client-esp32 && cargo build --release
# Flash ESP32 firmware. # Flash ESP32 firmware.
esp-flash: esp-flash:
@test -n "$(KFRAME_WIFI_SSID)" || (echo "Set KFRAME_WIFI_SSID, KFRAME_WIFI_PASS, KFRAME_SERVER_ADDR" && exit 1)
cd crates/client-esp32 && cargo espflash flash --port $(ESP_PORT) --release cd crates/client-esp32 && cargo espflash flash --port $(ESP_PORT) --release
# Flash and monitor ESP32. # Flash and monitor ESP32.
esp-run: esp-run:
@test -n "$(KFRAME_WIFI_SSID)" || (echo "Set KFRAME_WIFI_SSID, KFRAME_WIFI_PASS, KFRAME_SERVER_ADDR" && exit 1)
cd crates/client-esp32 && cargo espflash flash --port $(ESP_PORT) --release --monitor cd crates/client-esp32 && cargo espflash flash --port $(ESP_PORT) --release --monitor
# Monitor ESP32 serial output. # Monitor ESP32 serial output.

161
README.md Normal file
View File

@@ -0,0 +1,161 @@
# K-Frame
IoT dashboard system. A server aggregates data from external sources (weather APIs, media players, RSS feeds, webhooks) and pushes rendered updates to ESP32 display clients over TCP.
**Server** polls data sources, projects values through configurable widget mappings, computes layouts, and broadcasts binary frames to connected clients. Configuration via REST API + web UI.
**Clients** receive structured data + layout trees, compute bounding boxes, format text per display hint rules, and render to hardware. The client domain is chip-agnostic — the same rendering engine runs on ESP32 (ILI9341 TFT) and desktop (terminal).
**Web UI** (SPA) configures data sources, widgets, layouts, themes, and presets. Includes a live layout preview that mirrors what the physical display shows.
## Architecture
Hexagonal / ports-and-adapters with full CQRS. Domain logic has zero framework dependencies. Static dispatch everywhere (no `Box<dyn>`, no trait objects).
```
┌─────────────────── Server ───────────────────┐
│ domain/ entities, value objects, │
│ ports (traits) │
│ application/ use cases, data projection │
│ bootstrap/ composition root, polling │
│ │
│ adapters/ │
│ config-sqlite persistence │
│ http-api REST API (Axum) │
│ tcp-server binary protocol broadcast │
│ http-json external API polling │
│ media, rss source-specific adapters │
│ auth argon2 + JWT │
├─────────────────── Shared ───────────────────┤
│ protocol/ wire types, postcard serde │
│ api-types/ REST DTOs │
├─────────────────── Client ───────────────────┤
│ client-domain/ layout engine, render engine │
│ markup parser, scroll state │
│ client-application/ message handling │
│ client-esp32/ ESP32 firmware (ILI9341) │
│ client-desktop/ terminal debug client │
├──────────────────── SPA ─────────────────────┤
│ spa/ React + TanStack Router │
│ shadcn/ui + Tailwind │
└──────────────────────────────────────────────┘
```
### Key design decisions
- **postcard + serde** for the wire protocol — compact varint encoding, `no_std` compatible, no codegen
- **Static dispatch** — port traits used as generic bounds, not trait objects. Composition root resolves all types
- **Event-driven CQRS** — config mutations emit domain events, read model projected in memory
- **Domain-owned rendering** — client-domain owns text wrapping, alignment, color markup, scroll. DisplayPort is a thin pixel-pusher (`draw_text_span`, `fill_rect`, `flush`)
- **WiFi provisioning** — ESP32 boots into AP captive portal if no config in NVS, auto-falls back on WiFi failure
See `docs/adr/` for architectural decision records and `CONTEXT.md` for the domain glossary.
## Features
- **Data sources**: HTTP/JSON, weather, media (Subsonic/Navidrome), RSS, webhooks
- **Layout engine**: flexbox-like containers (row/column, fixed/flex sizing, gap, padding, justify-content, align-items)
- **Theming**: 5 configurable colors (primary, secondary, accent, text, background), live push to clients
- **Rich text**: inline color markup (`{primary}text{/}`, `{#FF0000}hex{/}`)
- **Overflow scroll**: bounce animation when content exceeds widget bounds, speed auto-derived from overflow
- **Captive portal**: ESP32 AP mode with DNS + HTTP config form for WiFi provisioning
- **Auth**: argon2 password hashing, JWT tokens, protected API routes
## Prerequisites
- **Rust** (stable) — server, desktop client, shared crates
- **Rust ESP32 toolchain** — `espup`, xtensa target. See [esp-rs book](https://docs.esp-rs.org/book/)
- **Node.js / Bun** — SPA development
- **SQLite** — server persistence (created automatically)
## Quick start
```bash
# 1. Clone and configure
cp .env.example .env
# Edit .env — set JWT_SECRET and KFRAME_ENCRYPTION_KEY
# 2. Start the server
make server
# 3. Start the SPA dev server (separate terminal)
cd spa && bun install && bun run dev
# Open http://localhost:5173
# 4. Register an account (first launch = setup mode)
# 5. Add a data source, create widgets, build a layout
```
### ESP32 client
```bash
# Build firmware
make esp-build
# Flash (default port: /dev/ttyACM0)
make esp-flash
# Flash with custom port
make esp-flash ESP_PORT=/dev/ttyUSB0
# Flash and monitor serial output
make esp-run
# On first boot: connect to "KFrame-Setup" WiFi, enter credentials in captive portal
```
### Desktop client
```bash
make desktop
# Connects to localhost:2699, prints render commands to terminal
```
## Development
```bash
# Full check suite (fmt + clippy + test)
make check
# Auto-fix formatting and clippy warnings
make fix
# Run tests only
make test
# SPA type checking
cd spa && bun run typecheck
```
### Project structure
| Path | Description |
|------|-------------|
| `crates/domain/` | Entities, value objects, port traits |
| `crates/application/` | Use cases (ConfigService, DataProjection) |
| `crates/protocol/` | Wire types, encode/decode (`no_std`) |
| `crates/bootstrap/` | Server composition root |
| `crates/adapters/` | All port implementations |
| `crates/client-domain/` | Display-agnostic rendering engine |
| `crates/client-application/` | Client message handling |
| `crates/client-esp32/` | ESP32 firmware |
| `crates/client-desktop/` | Terminal debug client |
| `crates/api-types/` | REST API DTOs |
| `spa/` | React SPA |
| `docs/adr/` | Architecture decision records |
| `CONTEXT.md` | Domain glossary |
## Contributing
1. Fork the repo
2. Create a feature branch
3. Run `make check` before pushing — CI runs the same checks
4. Open a PR with a clear description of what changed and why
The domain glossary in `CONTEXT.md` defines the canonical language. Use it in code, commits, and PRs. If you're adding a new concept, update the glossary.
Architecture decisions are documented in `docs/adr/`. If your change involves a hard-to-reverse design choice, write an ADR.
## License
[MIT](LICENSE)

View File

@@ -0,0 +1,11 @@
[package]
name = "kframe-auth"
version = "0.1.0"
edition = "2024"
[dependencies]
domain.workspace = true
jsonwebtoken = "9"
argon2 = { version = "0.5", features = ["std"] }
rand_core = { version = "0.6", features = ["getrandom"] }
serde = { version = "1", features = ["derive"] }

View File

@@ -0,0 +1,90 @@
use argon2::{
Argon2,
password_hash::{PasswordHasher, PasswordVerifier, SaltString},
};
use domain::{AuthPort, PasswordHashPort, UserId};
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
pub struct AuthConfig {
pub secret: String,
pub ttl_seconds: u64,
}
impl AuthConfig {
pub fn from_env() -> Result<Self, String> {
let secret = std::env::var("JWT_SECRET")
.map_err(|_| "JWT_SECRET env var is required".to_string())?;
if secret.is_empty() {
return Err("JWT_SECRET must not be empty".into());
}
let ttl_seconds = std::env::var("JWT_TTL_SECONDS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3600u64);
Ok(Self {
secret,
ttl_seconds,
})
}
}
#[derive(Serialize, Deserialize)]
struct Claims {
sub: u32,
exp: u64,
}
pub struct JwtAuthService {
config: AuthConfig,
}
impl JwtAuthService {
pub fn new(config: AuthConfig) -> Self {
Self { config }
}
}
impl AuthPort for JwtAuthService {
fn generate_token(&self, user_id: UserId) -> String {
let exp = jsonwebtoken::get_current_timestamp() + self.config.ttl_seconds;
let claims = Claims { sub: user_id, exp };
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(self.config.secret.as_bytes()),
)
.expect("JWT encoding should not fail")
}
fn validate_token(&self, token: &str) -> Option<UserId> {
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(self.config.secret.as_bytes()),
&Validation::default(),
)
.ok()?;
Some(data.claims.sub)
}
}
pub struct Argon2Hasher;
impl PasswordHashPort for Argon2Hasher {
async fn hash(&self, plain: &str) -> Result<String, String> {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(plain.as_bytes(), &salt)
.map_err(|e| e.to_string())?
.to_string();
Ok(hash)
}
async fn verify(&self, plain: &str, hash: &str) -> Result<bool, String> {
let parsed = argon2::password_hash::PasswordHash::new(hash).map_err(|e| e.to_string())?;
Ok(Argon2::default()
.verify_password(plain.as_bytes(), &parsed)
.is_ok())
}
}

View File

@@ -1,10 +1,9 @@
use domain::{
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
User, WidgetConfig, WidgetId,
};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::RwLock; use std::sync::RwLock;
use domain::{
ConfigRepository,
DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId,
WidgetConfig, WidgetId,
};
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum MemoryConfigError { pub enum MemoryConfigError {
@@ -16,97 +15,192 @@ pub struct MemoryConfigStore {
widgets: RwLock<HashMap<WidgetId, WidgetConfig>>, widgets: RwLock<HashMap<WidgetId, WidgetConfig>>,
data_sources: RwLock<HashMap<DataSourceId, DataSource>>, data_sources: RwLock<HashMap<DataSourceId, DataSource>>,
layout: RwLock<Option<Layout>>, layout: RwLock<Option<Layout>>,
theme: RwLock<Option<ThemeConfig>>,
presets: RwLock<HashMap<LayoutPresetId, LayoutPreset>>, presets: RwLock<HashMap<LayoutPresetId, LayoutPreset>>,
users: RwLock<Vec<User>>,
} }
impl MemoryConfigStore { impl Default for MemoryConfigStore {
pub fn new() -> Self { fn default() -> Self {
Self { Self {
widgets: RwLock::new(HashMap::new()), widgets: RwLock::new(HashMap::new()),
data_sources: RwLock::new(HashMap::new()), data_sources: RwLock::new(HashMap::new()),
layout: RwLock::new(None), layout: RwLock::new(None),
theme: RwLock::new(None),
presets: RwLock::new(HashMap::new()), presets: RwLock::new(HashMap::new()),
users: RwLock::new(Vec::new()),
} }
} }
} }
impl MemoryConfigStore {
pub fn new() -> Self {
Self::default()
}
}
impl ConfigRepository for MemoryConfigStore { impl ConfigRepository for MemoryConfigStore {
type Error = MemoryConfigError; type Error = MemoryConfigError;
async fn get_widget(&self, id: WidgetId) -> Result<Option<WidgetConfig>, Self::Error> { async fn get_widget(&self, id: WidgetId) -> Result<Option<WidgetConfig>, Self::Error> {
let guard = self.widgets.read().map_err(|_| MemoryConfigError::LockPoisoned)?; let guard = self
.widgets
.read()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
Ok(guard.get(&id).cloned()) Ok(guard.get(&id).cloned())
} }
async fn list_widgets(&self) -> Result<Vec<WidgetConfig>, Self::Error> { async fn list_widgets(&self) -> Result<Vec<WidgetConfig>, Self::Error> {
let guard = self.widgets.read().map_err(|_| MemoryConfigError::LockPoisoned)?; let guard = self
.widgets
.read()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
Ok(guard.values().cloned().collect()) Ok(guard.values().cloned().collect())
} }
async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> { async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> {
let mut guard = self.widgets.write().map_err(|_| MemoryConfigError::LockPoisoned)?; let mut guard = self
.widgets
.write()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
guard.insert(config.id, config.clone()); guard.insert(config.id, config.clone());
Ok(()) Ok(())
} }
async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error> { async fn delete_widget(&self, id: WidgetId) -> Result<(), Self::Error> {
let mut guard = self.widgets.write().map_err(|_| MemoryConfigError::LockPoisoned)?; let mut guard = self
.widgets
.write()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
guard.remove(&id); guard.remove(&id);
Ok(()) Ok(())
} }
async fn get_data_source(&self, id: DataSourceId) -> Result<Option<DataSource>, Self::Error> { async fn get_data_source(&self, id: DataSourceId) -> Result<Option<DataSource>, Self::Error> {
let guard = self.data_sources.read().map_err(|_| MemoryConfigError::LockPoisoned)?; let guard = self
.data_sources
.read()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
Ok(guard.get(&id).cloned()) Ok(guard.get(&id).cloned())
} }
async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error> { async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error> {
let guard = self.data_sources.read().map_err(|_| MemoryConfigError::LockPoisoned)?; let guard = self
.data_sources
.read()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
Ok(guard.values().cloned().collect()) Ok(guard.values().cloned().collect())
} }
async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> { async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> {
let mut guard = self.data_sources.write().map_err(|_| MemoryConfigError::LockPoisoned)?; let mut guard = self
.data_sources
.write()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
guard.insert(source.id, source.clone()); guard.insert(source.id, source.clone());
Ok(()) Ok(())
} }
async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error> { async fn delete_data_source(&self, id: DataSourceId) -> Result<(), Self::Error> {
let mut guard = self.data_sources.write().map_err(|_| MemoryConfigError::LockPoisoned)?; let mut guard = self
.data_sources
.write()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
guard.remove(&id); guard.remove(&id);
Ok(()) Ok(())
} }
async fn get_layout(&self) -> Result<Option<Layout>, Self::Error> { async fn get_layout(&self) -> Result<Option<Layout>, Self::Error> {
let guard = self.layout.read().map_err(|_| MemoryConfigError::LockPoisoned)?; let guard = self
.layout
.read()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
Ok(guard.clone()) Ok(guard.clone())
} }
async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error> { async fn save_layout(&self, layout: &Layout) -> Result<(), Self::Error> {
let mut guard = self.layout.write().map_err(|_| MemoryConfigError::LockPoisoned)?; let mut guard = self
.layout
.write()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
*guard = Some(layout.clone()); *guard = Some(layout.clone());
Ok(()) Ok(())
} }
async fn get_theme(&self) -> Result<Option<ThemeConfig>, Self::Error> {
let guard = self
.theme
.read()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
Ok(guard.clone())
}
async fn save_theme(&self, theme: &ThemeConfig) -> Result<(), Self::Error> {
let mut guard = self
.theme
.write()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
*guard = Some(theme.clone());
Ok(())
}
async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error> { async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error> {
let guard = self.presets.read().map_err(|_| MemoryConfigError::LockPoisoned)?; let guard = self
.presets
.read()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
Ok(guard.get(&id).cloned()) Ok(guard.get(&id).cloned())
} }
async fn list_presets(&self) -> Result<Vec<LayoutPreset>, Self::Error> { async fn list_presets(&self) -> Result<Vec<LayoutPreset>, Self::Error> {
let guard = self.presets.read().map_err(|_| MemoryConfigError::LockPoisoned)?; let guard = self
.presets
.read()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
Ok(guard.values().cloned().collect()) Ok(guard.values().cloned().collect())
} }
async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> { async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> {
let mut guard = self.presets.write().map_err(|_| MemoryConfigError::LockPoisoned)?; let mut guard = self
.presets
.write()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
guard.insert(preset.id, preset.clone()); guard.insert(preset.id, preset.clone());
Ok(()) Ok(())
} }
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> { async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> {
let mut guard = self.presets.write().map_err(|_| MemoryConfigError::LockPoisoned)?; let mut guard = self
.presets
.write()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
guard.remove(&id); guard.remove(&id);
Ok(()) Ok(())
} }
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
let guard = self
.users
.read()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
Ok(guard.iter().find(|u| u.username == username).cloned())
}
async fn save_user(&self, user: &User) -> Result<(), Self::Error> {
let mut guard = self
.users
.write()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
guard.retain(|u| u.id != user.id);
guard.push(user.clone());
Ok(())
}
async fn count_users(&self) -> Result<u32, Self::Error> {
let guard = self
.users
.read()
.map_err(|_| MemoryConfigError::LockPoisoned)?;
Ok(guard.len() as u32)
}
} }

View File

@@ -1,23 +1,37 @@
pub mod error; pub mod error;
mod serialization;
mod repository; mod repository;
mod serialization;
use domain::SecretStore;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use std::sync::Arc;
pub use error::SqliteConfigError; pub use error::SqliteConfigError;
pub struct SqliteConfigStore { pub struct SqliteConfigStore {
pool: SqlitePool, pool: SqlitePool,
secrets: Option<Arc<dyn SecretStore + Send + Sync>>,
} }
impl SqliteConfigStore { impl SqliteConfigStore {
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> { pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
Self::with_secrets(database_url, None).await
}
pub async fn with_secrets(
database_url: &str,
secrets: Option<Arc<dyn SecretStore + Send + Sync>>,
) -> Result<Self, sqlx::Error> {
let pool = SqlitePool::connect(database_url).await?; let pool = SqlitePool::connect(database_url).await?;
let store = Self { pool }; let store = Self { pool, secrets };
store.migrate().await?; store.migrate().await?;
Ok(store) Ok(store)
} }
pub(crate) fn secrets(&self) -> Option<&(dyn SecretStore + Send + Sync)> {
self.secrets.as_deref()
}
async fn migrate(&self) -> Result<(), sqlx::Error> { async fn migrate(&self) -> Result<(), sqlx::Error> {
sqlx::query( sqlx::query(
"CREATE TABLE IF NOT EXISTS widgets ( "CREATE TABLE IF NOT EXISTS widgets (
@@ -27,8 +41,10 @@ impl SqliteConfigStore {
data_source_id INTEGER NOT NULL, data_source_id INTEGER NOT NULL,
mappings TEXT NOT NULL, mappings TEXT NOT NULL,
max_data_size INTEGER NOT NULL max_data_size INTEGER NOT NULL
)" )",
).execute(&self.pool).await?; )
.execute(&self.pool)
.await?;
sqlx::query( sqlx::query(
"CREATE TABLE IF NOT EXISTS data_sources ( "CREATE TABLE IF NOT EXISTS data_sources (
@@ -37,23 +53,48 @@ impl SqliteConfigStore {
source_type TEXT NOT NULL, source_type TEXT NOT NULL,
poll_interval_secs INTEGER NOT NULL, poll_interval_secs INTEGER NOT NULL,
config TEXT NOT NULL config TEXT NOT NULL
)" )",
).execute(&self.pool).await?; )
.execute(&self.pool)
.await?;
sqlx::query( sqlx::query(
"CREATE TABLE IF NOT EXISTS layout ( "CREATE TABLE IF NOT EXISTS layout (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
data TEXT NOT NULL data TEXT NOT NULL
)" )",
).execute(&self.pool).await?; )
.execute(&self.pool)
.await?;
sqlx::query( sqlx::query(
"CREATE TABLE IF NOT EXISTS presets ( "CREATE TABLE IF NOT EXISTS presets (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
layout_data TEXT NOT NULL layout_data TEXT NOT NULL
)" )",
).execute(&self.pool).await?; )
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL
)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS theme (
id INTEGER PRIMARY KEY CHECK (id = 1),
data TEXT NOT NULL
)",
)
.execute(&self.pool)
.await?;
Ok(()) Ok(())
} }

View File

@@ -1,10 +1,13 @@
use domain::{DataSource, DataSourceId};
use crate::SqliteConfigStore; use crate::SqliteConfigStore;
use crate::error::SqliteConfigError; use crate::error::SqliteConfigError;
use crate::serialization::data_source as ser; use crate::serialization::data_source as ser;
use domain::{DataSource, DataSourceId};
impl SqliteConfigStore { impl SqliteConfigStore {
pub(crate) async fn get_data_source_impl(&self, id: DataSourceId) -> Result<Option<DataSource>, SqliteConfigError> { pub(crate) async fn get_data_source_impl(
&self,
id: DataSourceId,
) -> Result<Option<DataSource>, SqliteConfigError> {
let row = sqlx::query("SELECT * FROM data_sources WHERE id = ?") let row = sqlx::query("SELECT * FROM data_sources WHERE id = ?")
.bind(id as i64) .bind(id as i64)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -13,26 +16,34 @@ impl SqliteConfigStore {
match row { match row {
None => Ok(None), None => Ok(None),
Some(row) => Ok(Some(ser::data_source_from_row(&row)?)), Some(row) => Ok(Some(ser::data_source_from_row(&row, self.secrets())?)),
} }
} }
pub(crate) async fn list_data_sources_impl(&self) -> Result<Vec<DataSource>, SqliteConfigError> { pub(crate) async fn list_data_sources_impl(
&self,
) -> Result<Vec<DataSource>, SqliteConfigError> {
let rows = sqlx::query("SELECT * FROM data_sources") let rows = sqlx::query("SELECT * FROM data_sources")
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(SqliteConfigError::Sql)?; .map_err(SqliteConfigError::Sql)?;
rows.iter().map(|r| ser::data_source_from_row(r)).collect() let secrets = self.secrets();
rows.iter()
.map(|r| ser::data_source_from_row(r, secrets))
.collect()
} }
pub(crate) async fn save_data_source_impl(&self, source: &DataSource) -> Result<(), SqliteConfigError> { pub(crate) async fn save_data_source_impl(
let config_json = ser::data_source_config_to_json(&source.config)?; &self,
source: &DataSource,
) -> Result<(), SqliteConfigError> {
let config_json = ser::data_source_config_to_json(&source.config, self.secrets())?;
let type_str = ser::data_source_type_to_str(&source.source_type); let type_str = ser::data_source_type_to_str(&source.source_type);
sqlx::query( sqlx::query(
"INSERT OR REPLACE INTO data_sources (id, name, source_type, poll_interval_secs, config) "INSERT OR REPLACE INTO data_sources (id, name, source_type, poll_interval_secs, config)
VALUES (?, ?, ?, ?, ?)" VALUES (?, ?, ?, ?, ?)",
) )
.bind(source.id as i64) .bind(source.id as i64)
.bind(&source.name) .bind(&source.name)
@@ -46,7 +57,10 @@ impl SqliteConfigStore {
Ok(()) Ok(())
} }
pub(crate) async fn delete_data_source_impl(&self, id: DataSourceId) -> Result<(), SqliteConfigError> { pub(crate) async fn delete_data_source_impl(
&self,
id: DataSourceId,
) -> Result<(), SqliteConfigError> {
sqlx::query("DELETE FROM data_sources WHERE id = ?") sqlx::query("DELETE FROM data_sources WHERE id = ?")
.bind(id as i64) .bind(id as i64)
.execute(&self.pool) .execute(&self.pool)

View File

@@ -1,8 +1,8 @@
use sqlx::Row;
use domain::Layout;
use crate::SqliteConfigStore; use crate::SqliteConfigStore;
use crate::error::SqliteConfigError; use crate::error::SqliteConfigError;
use crate::serialization::layout as ser; use crate::serialization::layout as ser;
use domain::Layout;
use sqlx::Row;
impl SqliteConfigStore { impl SqliteConfigStore {
pub(crate) async fn get_layout_impl(&self) -> Result<Option<Layout>, SqliteConfigError> { pub(crate) async fn get_layout_impl(&self) -> Result<Option<Layout>, SqliteConfigError> {
@@ -23,13 +23,11 @@ impl SqliteConfigStore {
pub(crate) async fn save_layout_impl(&self, layout: &Layout) -> Result<(), SqliteConfigError> { pub(crate) async fn save_layout_impl(&self, layout: &Layout) -> Result<(), SqliteConfigError> {
let json = ser::layout_to_json(layout)?; let json = ser::layout_to_json(layout)?;
sqlx::query( sqlx::query("INSERT OR REPLACE INTO layout (id, data) VALUES (1, ?)")
"INSERT OR REPLACE INTO layout (id, data) VALUES (1, ?)" .bind(&json)
) .execute(&self.pool)
.bind(&json) .await
.execute(&self.pool) .map_err(SqliteConfigError::Sql)?;
.await
.map_err(SqliteConfigError::Sql)?;
Ok(()) Ok(())
} }

View File

@@ -1,16 +1,16 @@
mod widgets;
mod data_sources; mod data_sources;
mod layout; mod layout;
mod presets; mod presets;
mod theme;
mod users;
mod widgets;
use domain::{
ConfigRepository,
DataSource, DataSourceId,
Layout, LayoutPreset, LayoutPresetId,
WidgetConfig, WidgetId,
};
use crate::SqliteConfigStore; use crate::SqliteConfigStore;
use crate::error::SqliteConfigError; use crate::error::SqliteConfigError;
use domain::{
ConfigRepository, DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, ThemeConfig,
User, WidgetConfig, WidgetId,
};
impl ConfigRepository for SqliteConfigStore { impl ConfigRepository for SqliteConfigStore {
type Error = SqliteConfigError; type Error = SqliteConfigError;
@@ -70,4 +70,24 @@ impl ConfigRepository for SqliteConfigStore {
async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> { async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), Self::Error> {
self.delete_preset_impl(id).await self.delete_preset_impl(id).await
} }
async fn get_theme(&self) -> Result<Option<ThemeConfig>, Self::Error> {
self.get_theme_impl().await
}
async fn save_theme(&self, theme: &ThemeConfig) -> Result<(), Self::Error> {
self.save_theme_impl(theme).await
}
async fn get_user_by_username(&self, username: &str) -> Result<Option<User>, Self::Error> {
self.get_user_by_username_impl(username).await
}
async fn save_user(&self, user: &User) -> Result<(), Self::Error> {
self.save_user_impl(user).await
}
async fn count_users(&self) -> Result<u32, Self::Error> {
self.count_users_impl().await
}
} }

View File

@@ -1,10 +1,13 @@
use domain::{LayoutPreset, LayoutPresetId};
use crate::SqliteConfigStore; use crate::SqliteConfigStore;
use crate::error::SqliteConfigError; use crate::error::SqliteConfigError;
use crate::serialization::{layout as layout_ser, preset as ser}; use crate::serialization::{layout as layout_ser, preset as ser};
use domain::{LayoutPreset, LayoutPresetId};
impl SqliteConfigStore { impl SqliteConfigStore {
pub(crate) async fn get_preset_impl(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, SqliteConfigError> { pub(crate) async fn get_preset_impl(
&self,
id: LayoutPresetId,
) -> Result<Option<LayoutPreset>, SqliteConfigError> {
let row = sqlx::query("SELECT * FROM presets WHERE id = ?") let row = sqlx::query("SELECT * FROM presets WHERE id = ?")
.bind(id as i64) .bind(id as i64)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -23,26 +26,30 @@ impl SqliteConfigStore {
.await .await
.map_err(SqliteConfigError::Sql)?; .map_err(SqliteConfigError::Sql)?;
rows.iter().map(|r| ser::preset_from_row(r)).collect() rows.iter().map(ser::preset_from_row).collect()
} }
pub(crate) async fn save_preset_impl(&self, preset: &LayoutPreset) -> Result<(), SqliteConfigError> { pub(crate) async fn save_preset_impl(
&self,
preset: &LayoutPreset,
) -> Result<(), SqliteConfigError> {
let layout_json = layout_ser::layout_to_json(&preset.layout)?; let layout_json = layout_ser::layout_to_json(&preset.layout)?;
sqlx::query( sqlx::query("INSERT OR REPLACE INTO presets (id, name, layout_data) VALUES (?, ?, ?)")
"INSERT OR REPLACE INTO presets (id, name, layout_data) VALUES (?, ?, ?)" .bind(preset.id as i64)
) .bind(&preset.name)
.bind(preset.id as i64) .bind(&layout_json)
.bind(&preset.name) .execute(&self.pool)
.bind(&layout_json) .await
.execute(&self.pool) .map_err(SqliteConfigError::Sql)?;
.await
.map_err(SqliteConfigError::Sql)?;
Ok(()) Ok(())
} }
pub(crate) async fn delete_preset_impl(&self, id: LayoutPresetId) -> Result<(), SqliteConfigError> { pub(crate) async fn delete_preset_impl(
&self,
id: LayoutPresetId,
) -> Result<(), SqliteConfigError> {
sqlx::query("DELETE FROM presets WHERE id = ?") sqlx::query("DELETE FROM presets WHERE id = ?")
.bind(id as i64) .bind(id as i64)
.execute(&self.pool) .execute(&self.pool)

View File

@@ -0,0 +1,37 @@
use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use crate::serialization::theme as ser;
use domain::ThemeConfig;
use sqlx::Row;
impl SqliteConfigStore {
pub(crate) async fn get_theme_impl(&self) -> Result<Option<ThemeConfig>, SqliteConfigError> {
let row = sqlx::query("SELECT data FROM theme WHERE id = 1")
.fetch_optional(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
match row {
None => Ok(None),
Some(row) => {
let json: String = row.get("data");
Ok(Some(ser::theme_from_json(&json)?))
}
}
}
pub(crate) async fn save_theme_impl(
&self,
theme: &ThemeConfig,
) -> Result<(), SqliteConfigError> {
let json = ser::theme_to_json(theme)?;
sqlx::query("INSERT OR REPLACE INTO theme (id, data) VALUES (1, ?)")
.bind(&json)
.execute(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
Ok(())
}
}

View File

@@ -0,0 +1,48 @@
use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use domain::User;
use sqlx::Row;
impl SqliteConfigStore {
pub(crate) async fn get_user_by_username_impl(
&self,
username: &str,
) -> Result<Option<User>, SqliteConfigError> {
let row = sqlx::query("SELECT id, username, password_hash FROM users WHERE username = ?")
.bind(username)
.fetch_optional(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
Ok(row.map(|r| {
let id: i64 = r.get("id");
User {
id: id as u32,
username: r.get("username"),
password_hash: r.get("password_hash"),
}
}))
}
pub(crate) async fn save_user_impl(&self, user: &User) -> Result<(), SqliteConfigError> {
sqlx::query(
"INSERT INTO users (username, password_hash) VALUES (?, ?)
ON CONFLICT(username) DO UPDATE SET password_hash = excluded.password_hash",
)
.bind(&user.username)
.bind(&user.password_hash)
.execute(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
Ok(())
}
pub(crate) async fn count_users_impl(&self) -> Result<u32, SqliteConfigError> {
let row = sqlx::query("SELECT COUNT(*) as cnt FROM users")
.fetch_one(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
let count: i64 = row.get("cnt");
Ok(count as u32)
}
}

View File

@@ -1,10 +1,13 @@
use domain::{WidgetConfig, WidgetId};
use crate::SqliteConfigStore; use crate::SqliteConfigStore;
use crate::error::SqliteConfigError; use crate::error::SqliteConfigError;
use crate::serialization::widget as ser; use crate::serialization::widget as ser;
use domain::{WidgetConfig, WidgetId};
impl SqliteConfigStore { impl SqliteConfigStore {
pub(crate) async fn get_widget_impl(&self, id: WidgetId) -> Result<Option<WidgetConfig>, SqliteConfigError> { pub(crate) async fn get_widget_impl(
&self,
id: WidgetId,
) -> Result<Option<WidgetConfig>, SqliteConfigError> {
let row = sqlx::query("SELECT * FROM widgets WHERE id = ?") let row = sqlx::query("SELECT * FROM widgets WHERE id = ?")
.bind(id as i64) .bind(id as i64)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
@@ -23,10 +26,13 @@ impl SqliteConfigStore {
.await .await
.map_err(SqliteConfigError::Sql)?; .map_err(SqliteConfigError::Sql)?;
rows.iter().map(|r| ser::widget_from_row(r)).collect() rows.iter().map(ser::widget_from_row).collect()
} }
pub(crate) async fn save_widget_impl(&self, config: &WidgetConfig) -> Result<(), SqliteConfigError> { pub(crate) async fn save_widget_impl(
&self,
config: &WidgetConfig,
) -> Result<(), SqliteConfigError> {
let mappings_json = ser::mappings_to_json(&config.mappings)?; let mappings_json = ser::mappings_to_json(&config.mappings)?;
let hint_str = ser::display_hint_to_str(&config.display_hint); let hint_str = ser::display_hint_to_str(&config.display_hint);

View File

@@ -1,14 +1,20 @@
use std::time::Duration; use crate::error::SqliteConfigError;
use domain::{DataSource, DataSourceConfig, DataSourceType, SecretStore};
use sqlx::Row; use sqlx::Row;
use sqlx::sqlite::SqliteRow; use sqlx::sqlite::SqliteRow;
use domain::{DataSource, DataSourceConfig, DataSourceType}; use std::time::Duration;
use crate::error::SqliteConfigError;
const SENSITIVE_KEYS: &[&str] = &["password", "secret", "token", "api_key", "apikey"];
fn is_sensitive_key(key: &str) -> bool {
let lower = key.to_lowercase();
SENSITIVE_KEYS.iter().any(|s| lower.contains(s))
}
pub fn data_source_type_to_str(t: &DataSourceType) -> &'static str { pub fn data_source_type_to_str(t: &DataSourceType) -> &'static str {
match t { match t {
DataSourceType::Weather => "weather", DataSourceType::Weather => "weather",
DataSourceType::Media => "media", DataSourceType::Media => "media",
DataSourceType::Xtb => "xtb",
DataSourceType::Rss => "rss", DataSourceType::Rss => "rss",
DataSourceType::HttpJson => "http_json", DataSourceType::HttpJson => "http_json",
DataSourceType::Webhook => "webhook", DataSourceType::Webhook => "webhook",
@@ -19,41 +25,103 @@ fn data_source_type_from_str(s: &str) -> Result<DataSourceType, SqliteConfigErro
match s { match s {
"weather" => Ok(DataSourceType::Weather), "weather" => Ok(DataSourceType::Weather),
"media" => Ok(DataSourceType::Media), "media" => Ok(DataSourceType::Media),
"xtb" => Ok(DataSourceType::Xtb),
"rss" => Ok(DataSourceType::Rss), "rss" => Ok(DataSourceType::Rss),
"http_json" => Ok(DataSourceType::HttpJson), "http_json" => Ok(DataSourceType::HttpJson),
"webhook" => Ok(DataSourceType::Webhook), "webhook" => Ok(DataSourceType::Webhook),
_ => Err(SqliteConfigError::Serialization(format!("unknown source type: {s}"))), _ => Err(SqliteConfigError::Serialization(format!(
"unknown source type: {s}"
))),
} }
} }
pub fn data_source_config_to_json(config: &DataSourceConfig) -> Result<String, SqliteConfigError> { pub fn data_source_config_to_json(
config: &DataSourceConfig,
secrets: Option<&(dyn SecretStore + Send + Sync)>,
) -> Result<String, SqliteConfigError> {
let api_key = config.api_key.as_ref().map(|k| match secrets {
Some(s) => s.encrypt(k),
None => k.clone(),
});
let headers: Vec<(String, String)> = config
.headers
.iter()
.map(|(k, v)| {
let val = if is_sensitive_key(k) {
match secrets {
Some(s) => s.encrypt(v),
None => v.clone(),
}
} else {
v.clone()
};
(k.clone(), val)
})
.collect();
let v = serde_json::json!({ let v = serde_json::json!({
"url": config.url, "url": config.url,
"headers": config.headers, "headers": headers,
"api_key": config.api_key, "api_key": api_key,
"encrypted": secrets.is_some(),
}); });
serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string())) serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
} }
fn data_source_config_from_json(json: &str) -> Result<DataSourceConfig, SqliteConfigError> { fn data_source_config_from_json(
let v: serde_json::Value = serde_json::from_str(json) json: &str,
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?; secrets: Option<&(dyn SecretStore + Send + Sync)>,
) -> Result<DataSourceConfig, SqliteConfigError> {
let v: serde_json::Value =
serde_json::from_str(json).map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
let encrypted = v["encrypted"].as_bool().unwrap_or(false);
let url = v["url"].as_str().map(String::from); let url = v["url"].as_str().map(String::from);
let api_key = v["api_key"].as_str().map(String::from);
let api_key = v["api_key"].as_str().map(|k| {
if encrypted {
match secrets {
Some(s) => s.decrypt(k),
None => k.to_string(),
}
} else {
k.to_string()
}
});
let headers = match v["headers"].as_array() { let headers = match v["headers"].as_array() {
Some(arr) => arr.iter().filter_map(|h| { Some(arr) => arr
let pair = h.as_array()?; .iter()
Some((pair[0].as_str()?.into(), pair[1].as_str()?.into())) .filter_map(|h| {
}).collect(), let pair = h.as_array()?;
let key: String = pair[0].as_str()?.into();
let raw_val: &str = pair[1].as_str()?;
let val = if encrypted && is_sensitive_key(&key) {
match secrets {
Some(s) => s.decrypt(raw_val),
None => raw_val.to_string(),
}
} else {
raw_val.to_string()
};
Some((key, val))
})
.collect(),
None => vec![], None => vec![],
}; };
Ok(DataSourceConfig { url, headers, api_key }) Ok(DataSourceConfig {
url,
headers,
api_key,
})
} }
pub fn data_source_from_row(row: &SqliteRow) -> Result<DataSource, SqliteConfigError> { pub fn data_source_from_row(
row: &SqliteRow,
secrets: Option<&(dyn SecretStore + Send + Sync)>,
) -> Result<DataSource, SqliteConfigError> {
let id: i64 = row.get("id"); let id: i64 = row.get("id");
let name: String = row.get("name"); let name: String = row.get("name");
let type_str: String = row.get("source_type"); let type_str: String = row.get("source_type");
@@ -65,6 +133,6 @@ pub fn data_source_from_row(row: &SqliteRow) -> Result<DataSource, SqliteConfigE
name, name,
source_type: data_source_type_from_str(&type_str)?, source_type: data_source_type_from_str(&type_str)?,
poll_interval: Duration::from_secs(interval_secs as u64), poll_interval: Duration::from_secs(interval_secs as u64),
config: data_source_config_from_json(&config_json)?, config: data_source_config_from_json(&config_json, secrets)?,
}) })
} }

View File

@@ -1,5 +1,7 @@
use domain::{ContainerNode, Direction, Layout, LayoutChild, LayoutNode, Sizing};
use crate::error::SqliteConfigError; use crate::error::SqliteConfigError;
use domain::{
AlignItems, ContainerNode, Direction, JustifyContent, Layout, LayoutChild, LayoutNode, Sizing,
};
pub fn layout_to_json(layout: &Layout) -> Result<String, SqliteConfigError> { pub fn layout_to_json(layout: &Layout) -> Result<String, SqliteConfigError> {
let v = node_to_json(&layout.root); let v = node_to_json(&layout.root);
@@ -7,8 +9,8 @@ pub fn layout_to_json(layout: &Layout) -> Result<String, SqliteConfigError> {
} }
pub fn layout_from_json(json: &str) -> Result<Layout, SqliteConfigError> { pub fn layout_from_json(json: &str) -> Result<Layout, SqliteConfigError> {
let v: serde_json::Value = serde_json::from_str(json) let v: serde_json::Value =
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?; serde_json::from_str(json).map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
let root = node_from_json(&v)?; let root = node_from_json(&v)?;
Ok(Layout { root }) Ok(Layout { root })
} }
@@ -17,22 +19,39 @@ fn node_to_json(node: &LayoutNode) -> serde_json::Value {
match node { match node {
LayoutNode::Leaf(id) => serde_json::json!({ "type": "leaf", "widget_id": id }), LayoutNode::Leaf(id) => serde_json::json!({ "type": "leaf", "widget_id": id }),
LayoutNode::Container(c) => { LayoutNode::Container(c) => {
let children: Vec<serde_json::Value> = c.children.iter().map(|ch| { let children: Vec<serde_json::Value> = c
let sizing = match &ch.sizing { .children
Sizing::Fixed(px) => serde_json::json!({ "type": "fixed", "value": px }), .iter()
Sizing::Flex(w) => serde_json::json!({ "type": "flex", "value": w }), .map(|ch| {
}; let sizing = match &ch.sizing {
serde_json::json!({ Sizing::Fixed(px) => serde_json::json!({ "type": "fixed", "value": px }),
"sizing": sizing, Sizing::Flex(w) => serde_json::json!({ "type": "flex", "value": w }),
"node": node_to_json(&ch.node), };
serde_json::json!({
"sizing": sizing,
"node": node_to_json(&ch.node),
})
}) })
}).collect(); .collect();
serde_json::json!({ serde_json::json!({
"type": "container", "type": "container",
"direction": match c.direction { Direction::Row => "row", Direction::Column => "column" }, "direction": match c.direction { Direction::Row => "row", Direction::Column => "column" },
"gap": c.gap, "gap": c.gap,
"padding": c.padding, "padding": c.padding,
"justify_content": match c.justify_content {
JustifyContent::Start => "start",
JustifyContent::Center => "center",
JustifyContent::End => "end",
JustifyContent::SpaceBetween => "space_between",
JustifyContent::SpaceEvenly => "space_evenly",
},
"align_items": match c.align_items {
AlignItems::Start => "start",
AlignItems::Center => "center",
AlignItems::End => "end",
AlignItems::Stretch => "stretch",
},
"children": children, "children": children,
}) })
} }
@@ -44,25 +63,44 @@ fn node_from_json(v: &serde_json::Value) -> Result<LayoutNode, SqliteConfigError
match v["type"].as_str().ok_or_else(|| err("missing node type"))? { match v["type"].as_str().ok_or_else(|| err("missing node type"))? {
"leaf" => { "leaf" => {
let id = v["widget_id"].as_u64().ok_or_else(|| err("missing widget_id"))? as u16; let id = v["widget_id"]
.as_u64()
.ok_or_else(|| err("missing widget_id"))? as u16;
Ok(LayoutNode::Leaf(id)) Ok(LayoutNode::Leaf(id))
} }
"container" => { "container" => {
let direction = match v["direction"].as_str().ok_or_else(|| err("missing direction"))? { let direction = match v["direction"]
.as_str()
.ok_or_else(|| err("missing direction"))?
{
"row" => Direction::Row, "row" => Direction::Row,
"column" => Direction::Column, "column" => Direction::Column,
d => return Err(err(&format!("unknown direction: {d}"))), d => return Err(err(&format!("unknown direction: {d}"))),
}; };
let gap = v["gap"].as_u64().unwrap_or(0) as u8; let gap = v["gap"].as_u64().unwrap_or(0) as u8;
let padding = v["padding"].as_u64().unwrap_or(0) as u8; let padding = v["padding"].as_u64().unwrap_or(0) as u8;
let children = v["children"].as_array() let children = v["children"]
.as_array()
.ok_or_else(|| err("missing children"))? .ok_or_else(|| err("missing children"))?
.iter() .iter()
.map(|ch| { .map(|ch| {
let sizing_v = &ch["sizing"]; let sizing_v = &ch["sizing"];
let sizing = match sizing_v["type"].as_str().ok_or_else(|| err("missing sizing type"))? { let sizing = match sizing_v["type"]
"fixed" => Sizing::Fixed(sizing_v["value"].as_u64().ok_or_else(|| err("missing fixed value"))? as u16), .as_str()
"flex" => Sizing::Flex(sizing_v["value"].as_u64().ok_or_else(|| err("missing flex value"))? as u8), .ok_or_else(|| err("missing sizing type"))?
{
"fixed" => Sizing::Fixed(
sizing_v["value"]
.as_u64()
.ok_or_else(|| err("missing fixed value"))?
as u16,
),
"flex" => Sizing::Flex(
sizing_v["value"]
.as_u64()
.ok_or_else(|| err("missing flex value"))?
as u8,
),
s => return Err(err(&format!("unknown sizing: {s}"))), s => return Err(err(&format!("unknown sizing: {s}"))),
}; };
let node = node_from_json(&ch["node"])?; let node = node_from_json(&ch["node"])?;
@@ -70,7 +108,28 @@ fn node_from_json(v: &serde_json::Value) -> Result<LayoutNode, SqliteConfigError
}) })
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
Ok(LayoutNode::Container(ContainerNode { direction, gap, padding, children })) let justify_content = match v["justify_content"].as_str() {
Some("center") => JustifyContent::Center,
Some("end") => JustifyContent::End,
Some("space_between") => JustifyContent::SpaceBetween,
Some("space_evenly") => JustifyContent::SpaceEvenly,
_ => JustifyContent::Start,
};
let align_items = match v["align_items"].as_str() {
Some("center") => AlignItems::Center,
Some("end") => AlignItems::End,
Some("stretch") => AlignItems::Stretch,
_ => AlignItems::Start,
};
Ok(LayoutNode::Container(ContainerNode {
direction,
gap,
padding,
justify_content,
align_items,
children,
}))
} }
t => Err(err(&format!("unknown node type: {t}"))), t => Err(err(&format!("unknown node type: {t}"))),
} }

View File

@@ -1,4 +1,5 @@
pub mod widget;
pub mod data_source; pub mod data_source;
pub mod layout; pub mod layout;
pub mod preset; pub mod preset;
pub mod theme;
pub mod widget;

View File

@@ -1,8 +1,8 @@
use super::layout::layout_from_json;
use crate::error::SqliteConfigError;
use domain::LayoutPreset;
use sqlx::Row; use sqlx::Row;
use sqlx::sqlite::SqliteRow; use sqlx::sqlite::SqliteRow;
use domain::LayoutPreset;
use crate::error::SqliteConfigError;
use super::layout::layout_from_json;
pub fn preset_from_row(row: &SqliteRow) -> Result<LayoutPreset, SqliteConfigError> { pub fn preset_from_row(row: &SqliteRow) -> Result<LayoutPreset, SqliteConfigError> {
let id: i64 = row.get("id"); let id: i64 = row.get("id");

View File

@@ -0,0 +1,40 @@
use crate::error::SqliteConfigError;
use domain::{ThemeColor, ThemeConfig};
pub fn theme_to_json(theme: &ThemeConfig) -> Result<String, SqliteConfigError> {
let v = serde_json::json!({
"primary": color_to_json(&theme.primary),
"secondary": color_to_json(&theme.secondary),
"accent": color_to_json(&theme.accent),
"text": color_to_json(&theme.text),
"background": color_to_json(&theme.background),
});
serde_json::to_string(&v).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
}
pub fn theme_from_json(json: &str) -> Result<ThemeConfig, SqliteConfigError> {
let v: serde_json::Value =
serde_json::from_str(json).map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
let err = |msg: &str| SqliteConfigError::Serialization(msg.into());
Ok(ThemeConfig {
primary: color_from_json(&v["primary"]).map_err(|_| err("invalid primary"))?,
secondary: color_from_json(&v["secondary"]).map_err(|_| err("invalid secondary"))?,
accent: color_from_json(&v["accent"]).map_err(|_| err("invalid accent"))?,
text: color_from_json(&v["text"]).map_err(|_| err("invalid text"))?,
background: color_from_json(&v["background"]).map_err(|_| err("invalid background"))?,
})
}
fn color_to_json(c: &ThemeColor) -> serde_json::Value {
serde_json::json!({ "r": c.r, "g": c.g, "b": c.b })
}
fn color_from_json(v: &serde_json::Value) -> Result<ThemeColor, SqliteConfigError> {
let err = |msg: &str| SqliteConfigError::Serialization(msg.into());
Ok(ThemeColor {
r: v["r"].as_u64().ok_or_else(|| err("missing r"))? as u8,
g: v["g"].as_u64().ok_or_else(|| err("missing g"))? as u8,
b: v["b"].as_u64().ok_or_else(|| err("missing b"))? as u8,
})
}

View File

@@ -1,47 +1,59 @@
use crate::error::SqliteConfigError;
use domain::{DisplayHint, DisplayHintKind, KeyMapping, WidgetConfig};
use sqlx::Row; use sqlx::Row;
use sqlx::sqlite::SqliteRow; use sqlx::sqlite::SqliteRow;
use domain::{DisplayHint, KeyMapping, WidgetConfig};
use crate::error::SqliteConfigError;
pub fn display_hint_to_str(hint: &DisplayHint) -> &'static str { pub fn display_hint_to_str(hint: &DisplayHint) -> &'static str {
match hint { match hint.kind {
DisplayHint::IconValue => "icon_value", DisplayHintKind::IconValue => "icon_value",
DisplayHint::TextBlock => "text_block", DisplayHintKind::TextBlock => "text_block",
DisplayHint::KeyValue => "key_value", DisplayHintKind::KeyValue => "key_value",
} }
} }
fn display_hint_from_str(s: &str) -> Result<DisplayHint, SqliteConfigError> { fn display_hint_from_str(s: &str) -> Result<DisplayHint, SqliteConfigError> {
match s { match s {
"icon_value" => Ok(DisplayHint::IconValue), "icon_value" => Ok(DisplayHint::new(DisplayHintKind::IconValue)),
"text_block" => Ok(DisplayHint::TextBlock), "text_block" => Ok(DisplayHint::new(DisplayHintKind::TextBlock)),
"key_value" => Ok(DisplayHint::KeyValue), "key_value" => Ok(DisplayHint::new(DisplayHintKind::KeyValue)),
_ => Err(SqliteConfigError::Serialization(format!("unknown display hint: {s}"))), _ => Err(SqliteConfigError::Serialization(format!(
"unknown display hint: {s}"
))),
} }
} }
pub fn mappings_to_json(mappings: &[KeyMapping]) -> Result<String, SqliteConfigError> { pub fn mappings_to_json(mappings: &[KeyMapping]) -> Result<String, SqliteConfigError> {
let entries: Vec<serde_json::Value> = mappings.iter().map(|m| { let entries: Vec<serde_json::Value> = mappings
serde_json::json!({ .iter()
"source_path": m.source_path, .map(|m| {
"target_key": m.target_key, serde_json::json!({
"source_path": m.source_path,
"target_key": m.target_key,
})
}) })
}).collect(); .collect();
serde_json::to_string(&entries).map_err(|e| SqliteConfigError::Serialization(e.to_string())) serde_json::to_string(&entries).map_err(|e| SqliteConfigError::Serialization(e.to_string()))
} }
fn mappings_from_json(json: &str) -> Result<Vec<KeyMapping>, SqliteConfigError> { fn mappings_from_json(json: &str) -> Result<Vec<KeyMapping>, SqliteConfigError> {
let entries: Vec<serde_json::Value> = serde_json::from_str(json) let entries: Vec<serde_json::Value> =
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?; serde_json::from_str(json).map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
entries.iter().map(|v| { entries
Ok(KeyMapping { .iter()
source_path: v["source_path"].as_str() .map(|v| {
.ok_or_else(|| SqliteConfigError::Serialization("missing source_path".into()))?.into(), Ok(KeyMapping {
target_key: v["target_key"].as_str() source_path: v["source_path"]
.ok_or_else(|| SqliteConfigError::Serialization("missing target_key".into()))?.into(), .as_str()
.ok_or_else(|| SqliteConfigError::Serialization("missing source_path".into()))?
.into(),
target_key: v["target_key"]
.as_str()
.ok_or_else(|| SqliteConfigError::Serialization("missing target_key".into()))?
.into(),
})
}) })
}).collect() .collect()
} }
pub fn widget_from_row(row: &SqliteRow) -> Result<WidgetConfig, SqliteConfigError> { pub fn widget_from_row(row: &SqliteRow) -> Result<WidgetConfig, SqliteConfigError> {

View File

@@ -1,11 +1,10 @@
use std::time::Duration;
use domain::{
ConfigRepository, DisplayHint, KeyMapping, WidgetConfig,
DataSource, DataSourceConfig, DataSourceType,
Layout, LayoutNode, ContainerNode, LayoutChild, Direction, Sizing,
LayoutPreset,
};
use config_sqlite::SqliteConfigStore; use config_sqlite::SqliteConfigStore;
use domain::{
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
Direction, DisplayHint, DisplayHintKind, JustifyContent, KeyMapping, Layout, LayoutChild,
LayoutNode, LayoutPreset, Sizing, WidgetConfig,
};
use std::time::Duration;
async fn test_store() -> SqliteConfigStore { async fn test_store() -> SqliteConfigStore {
SqliteConfigStore::new("sqlite::memory:").await.unwrap() SqliteConfigStore::new("sqlite::memory:").await.unwrap()
@@ -15,11 +14,17 @@ fn weather_widget() -> WidgetConfig {
WidgetConfig { WidgetConfig {
id: 1, id: 1,
name: "weather".into(), name: "weather".into(),
display_hint: DisplayHint::IconValue, display_hint: DisplayHint::new(DisplayHintKind::IconValue),
data_source_id: 10, data_source_id: 10,
mappings: vec![ mappings: vec![
KeyMapping { source_path: "$.temp".into(), target_key: "temperature".into() }, KeyMapping {
KeyMapping { source_path: "$.icon".into(), target_key: "icon".into() }, source_path: "$.temp".into(),
target_key: "temperature".into(),
},
KeyMapping {
source_path: "$.icon".into(),
target_key: "icon".into(),
},
], ],
max_data_size: 2048, max_data_size: 2048,
} }
@@ -45,9 +50,17 @@ fn test_layout() -> Layout {
direction: Direction::Row, direction: Direction::Row,
gap: 4, gap: 4,
padding: 2, padding: 2,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![ children: vec![
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) }, LayoutChild {
LayoutChild { sizing: Sizing::Fixed(80), node: LayoutNode::Leaf(2) }, sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(1),
},
LayoutChild {
sizing: Sizing::Fixed(80),
node: LayoutNode::Leaf(2),
},
], ],
}), }),
} }
@@ -61,7 +74,7 @@ async fn save_and_retrieve_widget() {
let w = store.get_widget(1).await.unwrap().unwrap(); let w = store.get_widget(1).await.unwrap().unwrap();
assert_eq!(w.id, 1); assert_eq!(w.id, 1);
assert_eq!(w.name, "weather"); assert_eq!(w.name, "weather");
assert_eq!(w.display_hint, DisplayHint::IconValue); assert_eq!(w.display_hint, DisplayHint::new(DisplayHintKind::IconValue));
assert_eq!(w.data_source_id, 10); assert_eq!(w.data_source_id, 10);
assert_eq!(w.mappings.len(), 2); assert_eq!(w.mappings.len(), 2);
assert_eq!(w.mappings[0].source_path, "$.temp"); assert_eq!(w.mappings[0].source_path, "$.temp");
@@ -78,14 +91,17 @@ async fn get_nonexistent_widget_returns_none() {
async fn list_widgets_returns_all() { async fn list_widgets_returns_all() {
let store = test_store().await; let store = test_store().await;
store.save_widget(&weather_widget()).await.unwrap(); store.save_widget(&weather_widget()).await.unwrap();
store.save_widget(&WidgetConfig { store
id: 2, .save_widget(&WidgetConfig {
name: "portfolio".into(), id: 2,
display_hint: DisplayHint::KeyValue, name: "portfolio".into(),
data_source_id: 20, display_hint: DisplayHint::new(DisplayHintKind::KeyValue),
mappings: vec![], data_source_id: 20,
max_data_size: 1024, mappings: vec![],
}).await.unwrap(); max_data_size: 1024,
})
.await
.unwrap();
let widgets = store.list_widgets().await.unwrap(); let widgets = store.list_widgets().await.unwrap();
assert_eq!(widgets.len(), 2); assert_eq!(widgets.len(), 2);
@@ -172,12 +188,22 @@ async fn save_and_retrieve_preset() {
#[tokio::test] #[tokio::test]
async fn list_and_delete_presets() { async fn list_and_delete_presets() {
let store = test_store().await; let store = test_store().await;
store.save_preset(&LayoutPreset { store
id: 1, name: "a".into(), layout: test_layout(), .save_preset(&LayoutPreset {
}).await.unwrap(); id: 1,
store.save_preset(&LayoutPreset { name: "a".into(),
id: 2, name: "b".into(), layout: test_layout(), layout: test_layout(),
}).await.unwrap(); })
.await
.unwrap();
store
.save_preset(&LayoutPreset {
id: 2,
name: "b".into(),
layout: test_layout(),
})
.await
.unwrap();
assert_eq!(store.list_presets().await.unwrap().len(), 2); assert_eq!(store.list_presets().await.unwrap().len(), 2);

View File

@@ -1,5 +1,6 @@
use client_domain::{BoundingBox, DisplayPort}; use client_domain::{BoundingBox, Color, DisplayPort, FontSize};
#[derive(Default)]
pub struct TerminalDisplay; pub struct TerminalDisplay;
impl TerminalDisplay { impl TerminalDisplay {
@@ -11,23 +12,26 @@ impl TerminalDisplay {
impl DisplayPort for TerminalDisplay { impl DisplayPort for TerminalDisplay {
type Error = std::io::Error; type Error = std::io::Error;
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> { fn draw_text_span(
println!("[CLEAR] ({}, {}) {}x{}", bounds.x, bounds.y, bounds.width, bounds.height); &mut self,
text: &str,
x: u16,
y: u16,
color: Color,
font: FontSize,
) -> Result<(), Self::Error> {
println!(
"[TEXT] ({x}, {y}) {:?} #{:02X}{:02X}{:02X}: \"{text}\"",
font, color.0, color.1, color.2
);
Ok(()) Ok(())
} }
fn draw_text(&mut self, text: &str, x: u16, y: u16, bounds: BoundingBox) -> Result<(), Self::Error> { fn fill_rect(&mut self, bounds: BoundingBox, color: Color) -> Result<(), Self::Error> {
println!("[TEXT] ({x}, {y}) in {}x{}: \"{text}\"", bounds.width, bounds.height); println!(
Ok(()) "[FILL] ({}, {}) {}x{} #{:02X}{:02X}{:02X}",
} bounds.x, bounds.y, bounds.width, bounds.height, color.0, color.1, color.2
);
fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), Self::Error> {
println!("[ICON] ({x}, {y}): {icon}");
Ok(())
}
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> {
println!("[BG] ({}, {}) {}x{}", bounds.x, bounds.y, bounds.width, bounds.height);
Ok(()) Ok(())
} }

View File

@@ -11,6 +11,7 @@ axum.workspace = true
tower-http.workspace = true tower-http.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tokio.workspace = true
[dev-dependencies] [dev-dependencies]
tokio.workspace = true tokio.workspace = true
@@ -18,3 +19,4 @@ tower.workspace = true
serde_json.workspace = true serde_json.workspace = true
config-memory.workspace = true config-memory.workspace = true
tcp-server.workspace = true tcp-server.workspace = true
application.workspace = true

View File

@@ -0,0 +1,42 @@
use axum::{
extract::FromRequestParts,
http::{StatusCode, request::Parts},
};
use domain::{AuthPort, UserId};
pub struct AuthUser(pub UserId);
impl<C, E, W, B, R, A, H> FromRequestParts<crate::AppState<C, E, W, B, R, A, H>> for AuthUser
where
A: AuthPort + Send + Sync + 'static,
C: Send + Sync + 'static,
E: Send + Sync + 'static,
W: Send + Sync + 'static,
B: Send + Sync + 'static,
R: Send + Sync + 'static,
H: Send + Sync + 'static,
{
type Rejection = StatusCode;
async fn from_request_parts(
parts: &mut Parts,
state: &crate::AppState<C, E, W, B, R, A, H>,
) -> Result<Self, Self::Rejection> {
let header = parts
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
let token = header
.strip_prefix("Bearer ")
.ok_or(StatusCode::UNAUTHORIZED)?;
let user_id = state
.auth
.validate_token(token)
.ok_or(StatusCode::UNAUTHORIZED)?;
Ok(AuthUser(user_id))
}
}

View File

@@ -1,33 +1,86 @@
pub mod extractors;
mod routes; mod routes;
use std::sync::Arc;
use axum::Router; use axum::Router;
use domain::{
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
WidgetStateReader,
};
use std::sync::Arc;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use domain::{ConfigRepository, EventPublisher}; use tower_http::services::{ServeDir, ServeFile};
pub struct AppState<C, E> { pub struct AppState<C, E, W, B, R, A, H> {
pub config: Arc<C>, pub config: Arc<C>,
pub events: Arc<E>, pub events: Arc<E>,
pub widget_states: Arc<W>,
pub broadcaster: Arc<B>,
pub clients: Arc<R>,
pub auth: Arc<A>,
pub hasher: Arc<H>,
pub spa_dir: Option<String>,
} }
impl<C, E> Clone for AppState<C, E> { impl<C, E, W, B, R, A, H> Clone for AppState<C, E, W, B, R, A, H> {
fn clone(&self) -> Self { fn clone(&self) -> Self {
Self { Self {
config: self.config.clone(), config: self.config.clone(),
events: self.events.clone(), events: self.events.clone(),
widget_states: self.widget_states.clone(),
broadcaster: self.broadcaster.clone(),
clients: self.clients.clone(),
auth: self.auth.clone(),
hasher: self.hasher.clone(),
spa_dir: self.spa_dir.clone(),
} }
} }
} }
pub fn router<C, E>(state: AppState<C, E>) -> Router pub fn router<C, E, W, B, R, A, H>(state: AppState<C, E, W, B, R, A, H>) -> Router
where where
C: ConfigRepository + Send + Sync + 'static, C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send, C::Error: std::fmt::Debug + Send,
E: EventPublisher + Send + Sync + 'static, E: EventPublisher + Send + Sync + 'static,
E::Error: std::fmt::Debug + Send, E::Error: std::fmt::Debug + Send,
W: WidgetStateReader + Send + Sync + 'static,
B: BroadcastPort + Send + Sync + 'static,
B::Error: std::fmt::Debug + Send,
R: ClientRegistry + Send + Sync + 'static,
A: AuthPort + Send + Sync + 'static,
H: PasswordHashPort + Send + Sync + 'static,
{ {
Router::new() let spa_dir = state.spa_dir.clone();
let app = Router::new()
.nest("/api", routes::api_routes()) .nest("/api", routes::api_routes())
.layer(CorsLayer::permissive()) .layer(CorsLayer::permissive())
.with_state(state) .with_state(state);
if let Some(dir) = spa_dir {
let index = format!("{dir}/index.html");
app.fallback_service(ServeDir::new(&dir).fallback(ServeFile::new(index)))
} else {
app
}
}
pub async fn serve<C, E, W, B, R, A, H>(
addr: &str,
state: AppState<C, E, W, B, R, A, H>,
) -> Result<(), std::io::Error>
where
C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send,
E: EventPublisher + Send + Sync + 'static,
E::Error: std::fmt::Debug + Send,
W: WidgetStateReader + Send + Sync + 'static,
B: BroadcastPort + Send + Sync + 'static,
B::Error: std::fmt::Debug + Send,
R: ClientRegistry + Send + Sync + 'static,
A: AuthPort + Send + Sync + 'static,
H: PasswordHashPort + Send + Sync + 'static,
{
let app = router(state);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await
} }

View File

@@ -0,0 +1,85 @@
use crate::AppState;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::Json;
use domain::{AuthPort, ConfigRepository, PasswordHashPort};
use serde::{Deserialize, Serialize};
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
#[derive(Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(Serialize)]
pub struct LoginResponse {
pub token: String,
}
#[derive(Serialize)]
pub struct StatusResponse {
pub needs_setup: bool,
}
pub async fn login<C, E, W, B, R, A, H>(
State(state): S<C, E, W, B, R, A, H>,
Json(body): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, (StatusCode, String)>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
A: AuthPort,
H: PasswordHashPort,
{
let token = application::auth_service::login(
state.config.as_ref(),
state.auth.as_ref(),
state.hasher.as_ref(),
&body.username,
&body.password,
)
.await
.map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
Ok(Json(LoginResponse { token }))
}
pub async fn register<C, E, W, B, R, A, H>(
State(state): S<C, E, W, B, R, A, H>,
Json(body): Json<LoginRequest>,
) -> Result<StatusCode, (StatusCode, String)>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
H: PasswordHashPort,
{
application::auth_service::register(
state.config.as_ref(),
state.hasher.as_ref(),
&body.username,
&body.password,
)
.await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
Ok(StatusCode::CREATED)
}
pub async fn auth_status<C, E, W, B, R, A, H>(
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<StatusResponse>, StatusCode>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
{
let count = state
.config
.count_users()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(StatusResponse {
needs_setup: count == 0,
}))
}

View File

@@ -0,0 +1,29 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::ClientDto;
use axum::extract::State;
use axum::response::Json;
use domain::{ClientRegistry, ConfigRepository, EventPublisher};
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
pub async fn list_clients<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
) -> Json<Vec<ClientDto>>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
R: ClientRegistry,
{
Json(
state
.clients
.list_clients()
.iter()
.map(ClientDto::from)
.collect(),
)
}

View File

@@ -1,54 +1,113 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::DataSourceDto;
use application::ConfigService;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
response::Json, response::Json,
}; };
use domain::{ConfigRepository, EventPublisher}; use domain::{ConfigRepository, EventPublisher};
use application::ConfigService;
use crate::AppState;
use api_types::DataSourceDto;
type S<C, E> = State<AppState<C, E>>; type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
pub async fn list_data_sources<C, E>(State(state): S<C, E>) -> Result<Json<Vec<DataSourceDto>>, StatusCode> pub async fn list_data_sources<C, E, W, B, R, A, H>(
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug, _auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<Vec<DataSourceDto>>, StatusCode>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{ {
let sources = state.config.list_data_sources().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let sources = state
.config
.list_data_sources()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(sources.iter().map(DataSourceDto::from).collect())) Ok(Json(sources.iter().map(DataSourceDto::from).collect()))
} }
pub async fn get_data_source<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<Json<DataSourceDto>, StatusCode> pub async fn get_data_source<C, E, W, B, R, A, H>(
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug, _auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<Json<DataSourceDto>, StatusCode>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{ {
let source = state.config.get_data_source(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let source = state
.config
.get_data_source(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
match source { match source {
Some(s) => Ok(Json(DataSourceDto::from(&s))), Some(s) => Ok(Json(DataSourceDto::from(&s))),
None => Err(StatusCode::NOT_FOUND), None => Err(StatusCode::NOT_FOUND),
} }
} }
pub async fn create_data_source<C, E>(State(state): S<C, E>, Json(body): Json<DataSourceDto>) -> Result<StatusCode, (StatusCode, String)> pub async fn create_data_source<C, E, W, B, R, A, H>(
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug, _auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Json(body): Json<DataSourceDto>,
) -> Result<StatusCode, (StatusCode, String)>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{ {
let source = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?; let source = body
.into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.create_data_source(source).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; svc.create_data_source(source)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::CREATED) Ok(StatusCode::CREATED)
} }
pub async fn update_data_source<C, E>(State(state): S<C, E>, Path(_id): Path<u16>, Json(body): Json<DataSourceDto>) -> Result<StatusCode, (StatusCode, String)> pub async fn update_data_source<C, E, W, B, R, A, H>(
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug, _auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(_id): Path<u16>,
Json(body): Json<DataSourceDto>,
) -> Result<StatusCode, (StatusCode, String)>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{ {
let source = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?; let source = body
.into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.update_data_source(source).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; svc.update_data_source(source)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }
pub async fn delete_data_source<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<StatusCode, StatusCode> pub async fn delete_data_source<C, E, W, B, R, A, H>(
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug, _auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<StatusCode, StatusCode>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{ {
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.delete_data_source(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; svc.delete_data_source(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View File

@@ -1,27 +1,47 @@
use axum::{
extract::State,
http::StatusCode,
response::Json,
};
use domain::{ConfigRepository, EventPublisher};
use application::ConfigService;
use crate::AppState; use crate::AppState;
use crate::extractors::AuthUser;
use api_types::LayoutDto; use api_types::LayoutDto;
use application::ConfigService;
use axum::{extract::State, http::StatusCode, response::Json};
use domain::{ConfigRepository, EventPublisher};
type S<C, E> = State<AppState<C, E>>; type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
pub async fn get_layout<C, E>(State(state): S<C, E>) -> Result<Json<Option<LayoutDto>>, StatusCode> pub async fn get_layout<C, E, W, B, R, A, H>(
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug, _auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<Option<LayoutDto>>, StatusCode>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{ {
let layout = state.config.get_layout().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let layout = state
.config
.get_layout()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(layout.as_ref().map(LayoutDto::from))) Ok(Json(layout.as_ref().map(LayoutDto::from)))
} }
pub async fn update_layout<C, E>(State(state): S<C, E>, Json(body): Json<LayoutDto>) -> Result<StatusCode, (StatusCode, String)> pub async fn update_layout<C, E, W, B, R, A, H>(
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug, _auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Json(body): Json<LayoutDto>,
) -> Result<StatusCode, (StatusCode, String)>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{ {
let layout = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?; let layout = body
.into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.update_layout(layout).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; svc.update_layout(layout)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }

View File

@@ -1,27 +1,101 @@
mod widgets; mod auth;
mod clients;
mod data_sources; mod data_sources;
mod layout; mod layout;
mod presets; mod presets;
mod theme;
mod webhook;
mod widgets;
use axum::Router;
use axum::routing::{get, post, put, delete};
use domain::{ConfigRepository, EventPublisher};
use crate::AppState; use crate::AppState;
use axum::Router;
use axum::routing::{get, post};
use domain::{
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
WidgetStateReader,
};
pub fn api_routes<C, E>() -> Router<AppState<C, E>> pub fn api_routes<C, E, W, B, R, A, H>() -> Router<AppState<C, E, W, B, R, A, H>>
where where
C: ConfigRepository + Send + Sync + 'static, C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send, C::Error: std::fmt::Debug + Send,
E: EventPublisher + Send + Sync + 'static, E: EventPublisher + Send + Sync + 'static,
E::Error: std::fmt::Debug + Send, E::Error: std::fmt::Debug + Send,
W: WidgetStateReader + Send + Sync + 'static,
B: BroadcastPort + Send + Sync + 'static,
B::Error: std::fmt::Debug + Send,
R: ClientRegistry + Send + Sync + 'static,
A: AuthPort + Send + Sync + 'static,
H: PasswordHashPort + Send + Sync + 'static,
{ {
Router::new() Router::new()
.route("/widgets", get(widgets::list_widgets::<C, E>).post(widgets::create_widget::<C, E>)) // Public auth routes
.route("/widgets/{id}", get(widgets::get_widget::<C, E>).put(widgets::update_widget::<C, E>).delete(widgets::delete_widget::<C, E>)) .route(
.route("/data-sources", get(data_sources::list_data_sources::<C, E>).post(data_sources::create_data_source::<C, E>)) "/auth/status",
.route("/data-sources/{id}", get(data_sources::get_data_source::<C, E>).put(data_sources::update_data_source::<C, E>).delete(data_sources::delete_data_source::<C, E>)) get(auth::auth_status::<C, E, W, B, R, A, H>),
.route("/layout", get(layout::get_layout::<C, E>).put(layout::update_layout::<C, E>)) )
.route("/presets", get(presets::list_presets::<C, E>).post(presets::create_preset::<C, E>)) .route("/auth/login", post(auth::login::<C, E, W, B, R, A, H>))
.route("/presets/{id}", get(presets::get_preset::<C, E>).delete(presets::delete_preset::<C, E>)) .route(
.route("/presets/{id}/load", post(presets::load_preset::<C, E>)) "/auth/register",
post(auth::register::<C, E, W, B, R, A, H>),
)
// Protected routes
.route(
"/widgets",
get(widgets::list_widgets::<C, E, W, B, R, A, H>)
.post(widgets::create_widget::<C, E, W, B, R, A, H>),
)
.route(
"/widgets/{id}",
get(widgets::get_widget::<C, E, W, B, R, A, H>)
.put(widgets::update_widget::<C, E, W, B, R, A, H>)
.delete(widgets::delete_widget::<C, E, W, B, R, A, H>),
)
.route(
"/widgets/{id}/preview",
get(widgets::preview_widget::<C, E, W, B, R, A, H>),
)
.route(
"/data-sources",
get(data_sources::list_data_sources::<C, E, W, B, R, A, H>)
.post(data_sources::create_data_source::<C, E, W, B, R, A, H>),
)
.route(
"/data-sources/{id}",
get(data_sources::get_data_source::<C, E, W, B, R, A, H>)
.put(data_sources::update_data_source::<C, E, W, B, R, A, H>)
.delete(data_sources::delete_data_source::<C, E, W, B, R, A, H>),
)
.route(
"/layout",
get(layout::get_layout::<C, E, W, B, R, A, H>)
.put(layout::update_layout::<C, E, W, B, R, A, H>),
)
.route(
"/theme",
get(theme::get_theme::<C, E, W, B, R, A, H>)
.put(theme::update_theme::<C, E, W, B, R, A, H>),
)
.route(
"/presets",
get(presets::list_presets::<C, E, W, B, R, A, H>)
.post(presets::create_preset::<C, E, W, B, R, A, H>),
)
.route(
"/presets/{id}",
get(presets::get_preset::<C, E, W, B, R, A, H>)
.delete(presets::delete_preset::<C, E, W, B, R, A, H>),
)
.route(
"/presets/{id}/load",
post(presets::load_preset::<C, E, W, B, R, A, H>),
)
.route(
"/clients",
get(clients::list_clients::<C, E, W, B, R, A, H>),
)
.route(
"/webhook/{source_id}",
post(webhook::receive_webhook::<C, E, W, B, R, A, H>),
)
} }

View File

@@ -1,53 +1,109 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::{CreatePresetDto, PresetDto};
use application::ConfigService;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
response::Json, response::Json,
}; };
use domain::{ConfigRepository, EventPublisher}; use domain::{ConfigRepository, EventPublisher};
use application::ConfigService;
use crate::AppState;
use api_types::{PresetDto, CreatePresetDto};
type S<C, E> = State<AppState<C, E>>; type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
pub async fn list_presets<C, E>(State(state): S<C, E>) -> Result<Json<Vec<PresetDto>>, StatusCode> pub async fn list_presets<C, E, W, B, R, A, H>(
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug, _auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<Vec<PresetDto>>, StatusCode>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{ {
let presets = state.config.list_presets().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let presets = state
.config
.list_presets()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(presets.iter().map(PresetDto::from).collect())) Ok(Json(presets.iter().map(PresetDto::from).collect()))
} }
pub async fn get_preset<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<Json<PresetDto>, StatusCode> pub async fn get_preset<C, E, W, B, R, A, H>(
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug, _auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<Json<PresetDto>, StatusCode>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{ {
let preset = state.config.get_preset(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let preset = state
.config
.get_preset(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
match preset { match preset {
Some(p) => Ok(Json(PresetDto::from(&p))), Some(p) => Ok(Json(PresetDto::from(&p))),
None => Err(StatusCode::NOT_FOUND), None => Err(StatusCode::NOT_FOUND),
} }
} }
pub async fn create_preset<C, E>(State(state): S<C, E>, Json(body): Json<CreatePresetDto>) -> Result<StatusCode, (StatusCode, String)> pub async fn create_preset<C, E, W, B, R, A, H>(
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug, _auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Json(body): Json<CreatePresetDto>,
) -> Result<StatusCode, (StatusCode, String)>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{ {
let preset = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?; let preset = body
.into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.save_preset(preset).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; svc.save_preset(preset)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::CREATED) Ok(StatusCode::CREATED)
} }
pub async fn delete_preset<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<StatusCode, StatusCode> pub async fn delete_preset<C, E, W, B, R, A, H>(
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug, _auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<StatusCode, StatusCode>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{ {
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.delete_preset(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; svc.delete_preset(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
pub async fn load_preset<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<StatusCode, (StatusCode, String)> pub async fn load_preset<C, E, W, B, R, A, H>(
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug, _auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<StatusCode, (StatusCode, String)>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{ {
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.load_preset(id).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; svc.load_preset(id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }

View File

@@ -0,0 +1,46 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::ThemeDto;
use application::ConfigService;
use axum::{extract::State, http::StatusCode, response::Json};
use domain::{ConfigRepository, EventPublisher};
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
pub async fn get_theme<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<ThemeDto>, StatusCode>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{
let theme = state
.config
.get_theme()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.unwrap_or_default();
Ok(Json(ThemeDto::from(&theme)))
}
pub async fn update_theme<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Json(body): Json<ThemeDto>,
) -> Result<StatusCode, (StatusCode, String)>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{
let theme = body.into_domain();
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.update_theme(theme)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::OK)
}

View File

@@ -0,0 +1,88 @@
use crate::AppState;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::Json;
use domain::{BroadcastPort, ConfigRepository, EventPublisher, WidgetStateReader};
type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
pub async fn receive_webhook<C, E, W, B, R, A, H>(
State(state): S<C, E, W, B, R, A, H>,
Path(source_id): Path<u16>,
Json(body): Json<serde_json::Value>,
) -> Result<StatusCode, (StatusCode, String)>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
W: WidgetStateReader,
B: BroadcastPort,
B::Error: std::fmt::Debug,
{
let source = state
.config
.get_data_source(source_id)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?
.ok_or((StatusCode::NOT_FOUND, "data source not found".into()))?;
if source.source_type != domain::DataSourceType::Webhook {
return Err((
StatusCode::BAD_REQUEST,
"data source is not a webhook type".into(),
));
}
let raw = json_to_domain_value(body);
let widgets = state
.config
.list_widgets()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
let layout = state
.config
.get_layout()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
let changed = state
.widget_states
.apply_raw_data(source_id, &raw, &widgets)
.await;
if !changed.is_empty()
&& let Some(l) = &layout
{
let with_hints: Vec<_> = changed
.iter()
.filter_map(|(id, s)| {
let hint = widgets.iter().find(|w| w.id == *id)?.display_hint.clone();
Some((*id, hint, s.clone()))
})
.collect();
let _ = state.broadcaster.push_screen_update(l, &with_hints).await;
}
Ok(StatusCode::OK)
}
fn json_to_domain_value(json: serde_json::Value) -> domain::Value {
match json {
serde_json::Value::Null => domain::Value::Null,
serde_json::Value::Bool(b) => domain::Value::Bool(b),
serde_json::Value::Number(n) => domain::Value::Number(n.as_f64().unwrap_or(0.0)),
serde_json::Value::String(s) => domain::Value::String(s),
serde_json::Value::Array(arr) => {
domain::Value::Array(arr.into_iter().map(json_to_domain_value).collect())
}
serde_json::Value::Object(obj) => {
let map = obj
.into_iter()
.map(|(k, v)| (k, json_to_domain_value(v)))
.collect();
domain::Value::Object(map)
}
}
}

View File

@@ -1,54 +1,157 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::{CreateWidgetDto, WidgetDto};
use application::ConfigService;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
response::Json, response::Json,
}; };
use domain::{ConfigRepository, EventPublisher}; use domain::{ConfigRepository, EventPublisher, WidgetStateReader};
use application::ConfigService;
use crate::AppState;
use api_types::{WidgetDto, CreateWidgetDto};
type S<C, E> = State<AppState<C, E>>; type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
pub async fn list_widgets<C, E>(State(state): S<C, E>) -> Result<Json<Vec<WidgetDto>>, StatusCode> pub async fn list_widgets<C, E, W, B, R, A, H>(
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug, _auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
) -> Result<Json<Vec<WidgetDto>>, StatusCode>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{ {
let widgets = state.config.list_widgets().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let widgets = state
.config
.list_widgets()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(widgets.iter().map(WidgetDto::from).collect())) Ok(Json(widgets.iter().map(WidgetDto::from).collect()))
} }
pub async fn get_widget<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<Json<WidgetDto>, StatusCode> pub async fn get_widget<C, E, W, B, R, A, H>(
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug, _auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<Json<WidgetDto>, StatusCode>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{ {
let widget = state.config.get_widget(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let widget = state
.config
.get_widget(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
match widget { match widget {
Some(w) => Ok(Json(WidgetDto::from(&w))), Some(w) => Ok(Json(WidgetDto::from(&w))),
None => Err(StatusCode::NOT_FOUND), None => Err(StatusCode::NOT_FOUND),
} }
} }
pub async fn create_widget<C, E>(State(state): S<C, E>, Json(body): Json<CreateWidgetDto>) -> Result<StatusCode, (StatusCode, String)> pub async fn create_widget<C, E, W, B, R, A, H>(
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug, _auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Json(body): Json<CreateWidgetDto>,
) -> Result<StatusCode, (StatusCode, String)>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{ {
let widget = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?; let widget = body
.into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.create_widget(widget).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; svc.create_widget(widget)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::CREATED) Ok(StatusCode::CREATED)
} }
pub async fn update_widget<C, E>(State(state): S<C, E>, Path(_id): Path<u16>, Json(body): Json<CreateWidgetDto>) -> Result<StatusCode, (StatusCode, String)> pub async fn update_widget<C, E, W, B, R, A, H>(
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug, _auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(_id): Path<u16>,
Json(body): Json<CreateWidgetDto>,
) -> Result<StatusCode, (StatusCode, String)>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{ {
let widget = body.into_domain().map_err(|e| (StatusCode::BAD_REQUEST, e))?; let widget = body
.into_domain()
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.update_widget(widget).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; svc.update_widget(widget)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }
pub async fn delete_widget<C, E>(State(state): S<C, E>, Path(id): Path<u16>) -> Result<StatusCode, StatusCode> pub async fn delete_widget<C, E, W, B, R, A, H>(
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug, _auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<StatusCode, StatusCode>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{ {
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref());
svc.delete_widget(id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; svc.delete_widget(id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
pub async fn preview_widget<C, E, W, B, R, A, H>(
_auth: AuthUser,
State(state): S<C, E, W, B, R, A, H>,
Path(id): Path<u16>,
) -> Result<Json<serde_json::Value>, StatusCode>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
W: WidgetStateReader,
{
match state.widget_states.get_widget_state(id).await {
Some(ws) => {
let map: serde_json::Map<String, serde_json::Value> = ws
.data
.iter()
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
.collect();
Ok(Json(serde_json::Value::Object(map)))
}
None => Err(StatusCode::NOT_FOUND),
}
}
fn domain_value_to_json(v: &domain::Value) -> serde_json::Value {
match v {
domain::Value::Null => serde_json::Value::Null,
domain::Value::Bool(b) => serde_json::Value::Bool(*b),
domain::Value::Number(n) => serde_json::json!(n),
domain::Value::String(s) => serde_json::Value::String(s.clone()),
domain::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(domain_value_to_json).collect())
}
domain::Value::Object(obj) => {
let map = obj
.iter()
.map(|(k, v)| (k.clone(), domain_value_to_json(v)))
.collect();
serde_json::Value::Object(map)
}
}
}

View File

@@ -1,20 +1,63 @@
use std::sync::Arc; use application::DataProjection;
use axum::body::Body; use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
use tower::ServiceExt;
use config_memory::MemoryConfigStore; use config_memory::MemoryConfigStore;
use tcp_server::TcpEventBus; use domain::{AuthPort, PasswordHashPort, UserId};
use http_api::{AppState, router}; use http_api::{AppState, router};
use std::sync::Arc;
use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus};
use tower::ServiceExt;
struct TestAuth;
impl AuthPort for TestAuth {
fn generate_token(&self, _user_id: UserId) -> String {
"test-token".into()
}
fn validate_token(&self, token: &str) -> Option<UserId> {
if token == "test-token" { Some(1) } else { None }
}
}
struct TestHasher;
impl PasswordHashPort for TestHasher {
async fn hash(&self, _plain: &str) -> Result<String, String> {
Ok("hashed".into())
}
async fn verify(&self, _plain: &str, _hash: &str) -> Result<bool, String> {
Ok(true)
}
}
fn test_app() -> axum::Router { fn test_app() -> axum::Router {
let config = Arc::new(MemoryConfigStore::new()); let state = AppState {
let events = Arc::new(TcpEventBus::new(16)); config: Arc::new(MemoryConfigStore::new()),
let state = AppState { config, events }; events: Arc::new(TcpEventBus::new(16)),
widget_states: Arc::new(DataProjection::new()),
broadcaster: Arc::new(TcpBroadcaster::new(16)),
clients: Arc::new(ClientTracker::new()),
auth: Arc::new(TestAuth),
hasher: Arc::new(TestHasher),
spa_dir: None,
};
router(state) router(state)
} }
fn authed_json_request(method: &str, uri: &str, body: Option<&str>) -> Request<Body> {
let builder = Request::builder()
.method(method)
.uri(uri)
.header("content-type", "application/json")
.header("authorization", "Bearer test-token");
if let Some(b) = body {
builder.body(Body::from(b.to_string())).unwrap()
} else {
builder.body(Body::empty()).unwrap()
}
}
fn json_request(method: &str, uri: &str, body: Option<&str>) -> Request<Body> { fn json_request(method: &str, uri: &str, body: Option<&str>) -> Request<Body> {
let mut builder = Request::builder() let builder = Request::builder()
.method(method) .method(method)
.uri(uri) .uri(uri)
.header("content-type", "application/json"); .header("content-type", "application/json");
@@ -26,6 +69,16 @@ fn json_request(method: &str, uri: &str, body: Option<&str>) -> Request<Body> {
} }
} }
#[tokio::test]
async fn unauthenticated_request_returns_401() {
let app = test_app();
let resp = app
.oneshot(json_request("GET", "/api/widgets", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test] #[tokio::test]
async fn create_and_get_widget() { async fn create_and_get_widget() {
let app = test_app(); let app = test_app();
@@ -38,17 +91,24 @@ async fn create_and_get_widget() {
"mappings": [{"source_path": "$.temp", "target_key": "temperature"}] "mappings": [{"source_path": "$.temp", "target_key": "temperature"}]
}"#; }"#;
let resp = app.clone().oneshot(json_request("POST", "/api/widgets", Some(body))).await.unwrap(); let resp = app
.clone()
.oneshot(authed_json_request("POST", "/api/widgets", Some(body)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED); assert_eq!(resp.status(), StatusCode::CREATED);
let resp = app.oneshot(json_request("GET", "/api/widgets/1", None)).await.unwrap(); let resp = app
.oneshot(authed_json_request("GET", "/api/widgets/1", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["name"], "weather"); assert_eq!(json["name"], "weather");
assert_eq!(json["display_hint"], "icon_value");
assert_eq!(json["data_source_id"], 10);
} }
#[tokio::test] #[tokio::test]
@@ -58,11 +118,22 @@ async fn list_widgets() {
let w1 = r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#; let w1 = r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
let w2 = r#"{"id":2,"name":"b","display_hint":"key_value","data_source_id":2,"mappings":[]}"#; let w2 = r#"{"id":2,"name":"b","display_hint":"key_value","data_source_id":2,"mappings":[]}"#;
app.clone().oneshot(json_request("POST", "/api/widgets", Some(w1))).await.unwrap(); app.clone()
app.clone().oneshot(json_request("POST", "/api/widgets", Some(w2))).await.unwrap(); .oneshot(authed_json_request("POST", "/api/widgets", Some(w1)))
.await
.unwrap();
app.clone()
.oneshot(authed_json_request("POST", "/api/widgets", Some(w2)))
.await
.unwrap();
let resp = app.oneshot(json_request("GET", "/api/widgets", None)).await.unwrap(); let resp = app
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); .oneshot(authed_json_request("GET", "/api/widgets", None))
.await
.unwrap();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap(); let json: Vec<serde_json::Value> = serde_json::from_slice(&body).unwrap();
assert_eq!(json.len(), 2); assert_eq!(json.len(), 2);
} }
@@ -71,13 +142,24 @@ async fn list_widgets() {
async fn delete_widget() { async fn delete_widget() {
let app = test_app(); let app = test_app();
let body = r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#; let body =
app.clone().oneshot(json_request("POST", "/api/widgets", Some(body))).await.unwrap(); r#"{"id":1,"name":"a","display_hint":"icon_value","data_source_id":1,"mappings":[]}"#;
app.clone()
.oneshot(authed_json_request("POST", "/api/widgets", Some(body)))
.await
.unwrap();
let resp = app.clone().oneshot(json_request("DELETE", "/api/widgets/1", None)).await.unwrap(); let resp = app
.clone()
.oneshot(authed_json_request("DELETE", "/api/widgets/1", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT); assert_eq!(resp.status(), StatusCode::NO_CONTENT);
let resp = app.oneshot(json_request("GET", "/api/widgets/1", None)).await.unwrap(); let resp = app
.oneshot(authed_json_request("GET", "/api/widgets/1", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND); assert_eq!(resp.status(), StatusCode::NOT_FOUND);
} }
@@ -95,16 +177,18 @@ async fn create_and_get_data_source() {
"headers": [] "headers": []
}"#; }"#;
let resp = app.clone().oneshot(json_request("POST", "/api/data-sources", Some(body))).await.unwrap(); let resp = app
.clone()
.oneshot(authed_json_request("POST", "/api/data-sources", Some(body)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED); assert_eq!(resp.status(), StatusCode::CREATED);
let resp = app.oneshot(json_request("GET", "/api/data-sources/10", None)).await.unwrap(); let resp = app
.oneshot(authed_json_request("GET", "/api/data-sources/10", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["name"], "weather_api");
assert_eq!(json["poll_interval_secs"], 300);
} }
#[tokio::test] #[tokio::test]
@@ -124,26 +208,43 @@ async fn update_and_get_layout() {
} }
}"#; }"#;
let resp = app.clone().oneshot(json_request("PUT", "/api/layout", Some(body))).await.unwrap(); let resp = app
.clone()
.oneshot(authed_json_request("PUT", "/api/layout", Some(body)))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.status(), StatusCode::OK);
let resp = app.oneshot(json_request("GET", "/api/layout", None)).await.unwrap(); let resp = app
.oneshot(authed_json_request("GET", "/api/layout", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["root"]["type"], "container");
assert_eq!(json["root"]["direction"], "row");
assert_eq!(json["root"]["children"].as_array().unwrap().len(), 2);
} }
#[tokio::test] #[tokio::test]
async fn get_nonexistent_returns_404() { async fn get_nonexistent_returns_404() {
let app = test_app(); let app = test_app();
let resp = app.clone().oneshot(json_request("GET", "/api/widgets/99", None)).await.unwrap(); let resp = app
assert_eq!(resp.status(), StatusCode::NOT_FOUND); .clone()
.oneshot(authed_json_request("GET", "/api/widgets/99", None))
let resp = app.oneshot(json_request("GET", "/api/data-sources/99", None)).await.unwrap(); .await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND); assert_eq!(resp.status(), StatusCode::NOT_FOUND);
} }
#[tokio::test]
async fn auth_status_returns_needs_setup() {
let app = test_app();
let resp = app
.oneshot(json_request("GET", "/api/auth/status", None))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["needs_setup"], true);
}

View File

@@ -14,26 +14,32 @@ pub enum HttpJsonError {
Parse(String), Parse(String),
} }
impl HttpJsonAdapter { impl Default for HttpJsonAdapter {
pub fn new() -> Self { fn default() -> Self {
Self { Self {
client: reqwest::Client::new(), client: reqwest::Client::new(),
} }
} }
} }
impl HttpJsonAdapter {
pub fn new() -> Self {
Self::default()
}
}
fn json_to_value(json: serde_json::Value) -> Value { fn json_to_value(json: serde_json::Value) -> Value {
match json { match json {
serde_json::Value::Null => Value::Null, serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(b) => Value::Bool(b), serde_json::Value::Bool(b) => Value::Bool(b),
serde_json::Value::Number(n) => Value::Number(n.as_f64().unwrap_or(0.0)), serde_json::Value::Number(n) => Value::Number(n.as_f64().unwrap_or(0.0)),
serde_json::Value::String(s) => Value::String(s), serde_json::Value::String(s) => Value::String(s),
serde_json::Value::Array(arr) => { serde_json::Value::Array(arr) => Value::Array(arr.into_iter().map(json_to_value).collect()),
Value::Array(arr.into_iter().map(json_to_value).collect()) serde_json::Value::Object(map) => Value::Object(
} map.into_iter()
serde_json::Value::Object(map) => { .map(|(k, v)| (k, json_to_value(v)))
Value::Object(map.into_iter().map(|(k, v)| (k, json_to_value(v))).collect()) .collect(),
} ),
} }
} }

View File

@@ -1,19 +1,23 @@
use std::time::Duration; use axum::{Router, response::Json, routing::get};
use axum::{Router, routing::get, response::Json};
use domain::{DataSource, DataSourceConfig, DataSourcePort, DataSourceType, Value}; use domain::{DataSource, DataSourceConfig, DataSourcePort, DataSourceType, Value};
use http_json::HttpJsonAdapter; use http_json::HttpJsonAdapter;
use std::time::Duration;
async fn start_fake_api() -> String { async fn start_fake_api() -> String {
let app = Router::new() let app = Router::new()
.route("/weather", get(|| async { .route(
Json(serde_json::json!({ "/weather",
"main": {"temp": 5.4, "humidity": 80}, get(|| async {
"weather": [{"icon": "cloud_rain"}] Json(serde_json::json!({
})) "main": {"temp": 5.4, "humidity": 80},
})) "weather": [{"icon": "cloud_rain"}]
.route("/simple", get(|| async { }))
Json(serde_json::json!({"value": "hello"})) }),
})) )
.route(
"/simple",
get(|| async { Json(serde_json::json!({"value": "hello"})) }),
)
.route("/not-json", get(|| async { "plain text" })); .route("/not-json", get(|| async { "plain text" }));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
@@ -46,10 +50,7 @@ async fn polls_url_and_returns_nested_json_as_value() {
let result = adapter.poll(&source).await.unwrap(); let result = adapter.poll(&source).await.unwrap();
assert_eq!( assert_eq!(result.get_path("$.main.temp"), Some(&Value::Number(5.4)));
result.get_path("$.main.temp"),
Some(&Value::Number(5.4))
);
assert_eq!( assert_eq!(
result.get_path("$.main.humidity"), result.get_path("$.main.humidity"),
Some(&Value::Number(80.0)) Some(&Value::Number(80.0))

View File

@@ -8,6 +8,8 @@ domain.workspace = true
reqwest.workspace = true reqwest.workspace = true
serde_json.workspace = true serde_json.workspace = true
thiserror.workspace = true thiserror.workspace = true
md5 = "0.7"
fastrand = "2"
[dev-dependencies] [dev-dependencies]
tokio.workspace = true tokio.workspace = true

View File

@@ -4,6 +4,8 @@ pub enum MediaError {
Request(#[from] reqwest::Error), Request(#[from] reqwest::Error),
#[error("no url configured")] #[error("no url configured")]
NoUrl, NoUrl,
#[error("missing field in headers: {0}")]
MissingField(&'static str),
#[error("parse: {0}")] #[error("parse: {0}")]
Parse(String), Parse(String),
} }

View File

@@ -2,33 +2,61 @@ mod error;
pub use error::MediaError; pub use error::MediaError;
use std::collections::BTreeMap;
use domain::{DataSource, DataSourcePort, Value}; use domain::{DataSource, DataSourcePort, Value};
use std::collections::BTreeMap;
pub struct MediaAdapter { pub struct MediaAdapter {
client: reqwest::Client, client: reqwest::Client,
} }
impl MediaAdapter { impl Default for MediaAdapter {
pub fn new() -> Self { fn default() -> Self {
Self { Self {
client: reqwest::Client::new(), client: reqwest::Client::new(),
} }
} }
} }
impl MediaAdapter {
pub fn new() -> Self {
Self::default()
}
}
fn find_header<'a>(headers: &'a [(String, String)], key: &str) -> Option<&'a str> {
headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.map(|(_, v)| v.as_str())
}
fn subsonic_token(password: &str, salt: &str) -> String {
format!("{:x}", md5::compute(format!("{password}{salt}")))
}
impl DataSourcePort for MediaAdapter { impl DataSourcePort for MediaAdapter {
type Error = MediaError; type Error = MediaError;
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> { async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
let base_url = source.config.url.as_ref().ok_or(MediaError::NoUrl)?; let base_url = source.config.url.as_ref().ok_or(MediaError::NoUrl)?;
let api_key = source.config.api_key.as_deref().unwrap_or(""); let username = find_header(&source.config.headers, "username")
.ok_or(MediaError::MissingField("username"))?;
let password = find_header(&source.config.headers, "password")
.ok_or(MediaError::MissingField("password"))?;
let salt: String = (0..12).map(|_| fastrand::alphanumeric()).collect();
let token = subsonic_token(password, &salt);
let url = format!( let url = format!(
"{base_url}/rest/getNowPlaying.view?u=kframe&t={api_key}&s=salt&v=1.16.1&c=kframe&f=json" "{base_url}/rest/getNowPlaying.view?u={username}&t={token}&s={salt}&v=1.16.1&c=kframe&f=json"
); );
let resp = self.client.get(&url).send().await.map_err(MediaError::Request)?; let resp = self
.client
.get(&url)
.send()
.await
.map_err(MediaError::Request)?;
let json: serde_json::Value = resp.json().await.map_err(MediaError::Request)?; let json: serde_json::Value = resp.json().await.map_err(MediaError::Request)?;
let entries = json["subsonic-response"]["nowPlaying"]["entry"] let entries = json["subsonic-response"]["nowPlaying"]["entry"]
@@ -45,15 +73,18 @@ impl DataSourcePort for MediaAdapter {
let entry = &entries[0]; let entry = &entries[0];
let mut result = BTreeMap::new(); let mut result = BTreeMap::new();
result.insert("playing".into(), Value::Bool(true)); result.insert("playing".into(), Value::Bool(true));
result.insert("title".into(), Value::String( result.insert(
entry["title"].as_str().unwrap_or("Unknown").into() "title".into(),
)); Value::String(entry["title"].as_str().unwrap_or("Unknown").into()),
result.insert("artist".into(), Value::String( );
entry["artist"].as_str().unwrap_or("Unknown").into() result.insert(
)); "artist".into(),
result.insert("album".into(), Value::String( Value::String(entry["artist"].as_str().unwrap_or("Unknown").into()),
entry["album"].as_str().unwrap_or("Unknown").into() );
)); result.insert(
"album".into(),
Value::String(entry["album"].as_str().unwrap_or("Unknown").into()),
);
if let Some(duration) = entry["duration"].as_u64() { if let Some(duration) = entry["duration"].as_u64() {
result.insert("duration".into(), Value::Number(duration as f64)); result.insert("duration".into(), Value::Number(duration as f64));

View File

@@ -1,6 +1,6 @@
use std::time::Duration;
use domain::{DataSource, DataSourceConfig, DataSourcePort, DataSourceType, Value}; use domain::{DataSource, DataSourceConfig, DataSourcePort, DataSourceType, Value};
use media_adapter::MediaAdapter; use media_adapter::MediaAdapter;
use std::time::Duration;
fn subsonic_response(playing: bool) -> serde_json::Value { fn subsonic_response(playing: bool) -> serde_json::Value {
if playing { if playing {
@@ -28,10 +28,10 @@ fn subsonic_response(playing: bool) -> serde_json::Value {
} }
async fn start_fake_subsonic(playing: bool) -> String { async fn start_fake_subsonic(playing: bool) -> String {
let app = axum::Router::new() let app = axum::Router::new().route(
.route("/rest/getNowPlaying.view", axum::routing::get(move || async move { "/rest/getNowPlaying.view",
axum::response::Json(subsonic_response(playing)) axum::routing::get(move || async move { axum::response::Json(subsonic_response(playing)) }),
})); );
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap(); let addr = listener.local_addr().unwrap();
@@ -47,8 +47,11 @@ fn make_source(url: String) -> DataSource {
poll_interval: Duration::from_secs(5), poll_interval: Duration::from_secs(5),
config: DataSourceConfig { config: DataSourceConfig {
url: Some(url), url: Some(url),
headers: vec![], headers: vec![
api_key: Some("testtoken".into()), ("username".into(), "test".into()),
("password".into(), "testpass".into()),
],
api_key: None,
}, },
} }
} }
@@ -62,8 +65,14 @@ async fn returns_now_playing_info() {
let result = adapter.poll(&source).await.unwrap(); let result = adapter.poll(&source).await.unwrap();
assert_eq!(result.get_path("$.playing"), Some(&Value::Bool(true))); assert_eq!(result.get_path("$.playing"), Some(&Value::Bool(true)));
assert_eq!(result.get_path("$.title"), Some(&Value::String("Believer".into()))); assert_eq!(
assert_eq!(result.get_path("$.artist"), Some(&Value::String("Imagine Dragons".into()))); result.get_path("$.title"),
Some(&Value::String("Believer".into()))
);
assert_eq!(
result.get_path("$.artist"),
Some(&Value::String("Imagine Dragons".into()))
);
} }
#[tokio::test] #[tokio::test]

View File

@@ -6,7 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
domain.workspace = true domain.workspace = true
reqwest.workspace = true reqwest.workspace = true
quick-xml = { version = "0.37", features = ["serialize"] } quick-xml = { version = "0.40", features = ["serialize"] }
serde.workspace = true serde.workspace = true
thiserror.workspace = true thiserror.workspace = true

View File

@@ -10,21 +10,32 @@ pub struct RssAdapter {
client: reqwest::Client, client: reqwest::Client,
} }
impl RssAdapter { impl Default for RssAdapter {
pub fn new() -> Self { fn default() -> Self {
Self { Self {
client: reqwest::Client::new(), client: reqwest::Client::new(),
} }
} }
} }
impl RssAdapter {
pub fn new() -> Self {
Self::default()
}
}
impl DataSourcePort for RssAdapter { impl DataSourcePort for RssAdapter {
type Error = RssError; type Error = RssError;
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> { async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
let url = source.config.url.as_ref().ok_or(RssError::NoUrl)?; let url = source.config.url.as_ref().ok_or(RssError::NoUrl)?;
let resp = self.client.get(url).send().await.map_err(RssError::Request)?; let resp = self
.client
.get(url)
.send()
.await
.map_err(RssError::Request)?;
let xml = resp.text().await.map_err(RssError::Request)?; let xml = resp.text().await.map_err(RssError::Request)?;
parser::parse_rss(&xml) parser::parse_rss(&xml)

View File

@@ -29,15 +29,15 @@ pub fn parse_rss(xml: &str) -> Result<Value, RssError> {
} }
Ok(Event::End(e)) => { Ok(Event::End(e)) => {
let tag = String::from_utf8_lossy(e.name().as_ref()).to_string(); let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
if tag == "item" { if tag == "item"
if let Some(item) = current_item.take() { && let Some(item) = current_item.take()
items.push(Value::Object(item)); {
} items.push(Value::Object(item));
} }
current_tag.clear(); current_tag.clear();
} }
Ok(Event::Text(e)) => { Ok(Event::Text(e)) => {
let text = e.unescape().unwrap_or_default().to_string(); let text = String::from_utf8_lossy(e.as_ref()).to_string();
if !current_tag.is_empty() && !text.trim().is_empty() { if !current_tag.is_empty() && !text.trim().is_empty() {
if let Some(item) = current_item.as_mut() { if let Some(item) = current_item.as_mut() {
item.insert(current_tag.clone(), Value::String(text)); item.insert(current_tag.clone(), Value::String(text));
@@ -52,10 +52,10 @@ pub fn parse_rss(xml: &str) -> Result<Value, RssError> {
} }
Ok(Event::CData(e)) => { Ok(Event::CData(e)) => {
let text = String::from_utf8_lossy(&e).to_string(); let text = String::from_utf8_lossy(&e).to_string();
if !current_tag.is_empty() { if !current_tag.is_empty()
if let Some(item) = current_item.as_mut() { && let Some(item) = current_item.as_mut()
item.insert(current_tag.clone(), Value::String(text)); {
} item.insert(current_tag.clone(), Value::String(text));
} }
} }
Ok(Event::Eof) => break, Ok(Event::Eof) => break,

View File

@@ -1,5 +1,5 @@
use domain::Value; use domain::Value;
use rss_adapter::{parse_rss, RssError}; use rss_adapter::{RssError, parse_rss};
const SAMPLE_RSS: &str = r#"<?xml version="1.0" encoding="UTF-8"?> const SAMPLE_RSS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"> <rss version="2.0">
@@ -23,8 +23,20 @@ const SAMPLE_RSS: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
fn parses_rss_into_value() { fn parses_rss_into_value() {
let result = parse_rss(SAMPLE_RSS).unwrap(); let result = parse_rss(SAMPLE_RSS).unwrap();
assert_eq!(result.get_path("$.title"), Some(&Value::String("Test Feed".into()))); assert_eq!(
assert_eq!(result.get_path("$.items[0].title"), Some(&Value::String("First Article".into()))); result.get_path("$.title"),
assert_eq!(result.get_path("$.items[1].title"), Some(&Value::String("Second Article".into()))); Some(&Value::String("Test Feed".into()))
assert_eq!(result.get_path("$.items[0].description"), Some(&Value::String("Description of first article".into()))); );
assert_eq!(
result.get_path("$.items[0].title"),
Some(&Value::String("First Article".into()))
);
assert_eq!(
result.get_path("$.items[1].title"),
Some(&Value::String("Second Article".into()))
);
assert_eq!(
result.get_path("$.items[0].description"),
Some(&Value::String("Description of first article".into()))
);
} }

View File

@@ -0,0 +1,11 @@
[package]
name = "secret-store"
version = "0.1.0"
edition = "2024"
[dependencies]
domain.workspace = true
aes-gcm = "0.10"
base64 = "0.22"
hex = "0.4"
rand_core = { version = "0.6", features = ["getrandom"] }

View File

@@ -0,0 +1,56 @@
use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce, aead::Aead};
use base64::{Engine, engine::general_purpose::STANDARD as B64};
use domain::SecretStore;
use rand_core::{OsRng, RngCore};
pub struct AesSecretStore {
key: Key<Aes256Gcm>,
}
impl AesSecretStore {
pub fn from_env() -> Result<Self, String> {
let hex_key = std::env::var("KFRAME_ENCRYPTION_KEY")
.map_err(|_| "KFRAME_ENCRYPTION_KEY env var is required".to_string())?;
let bytes = hex::decode(&hex_key)
.map_err(|e| format!("KFRAME_ENCRYPTION_KEY must be 64 hex chars: {e}"))?;
if bytes.len() != 32 {
return Err(format!(
"KFRAME_ENCRYPTION_KEY must be 32 bytes (64 hex chars), got {}",
bytes.len()
));
}
let key = Key::<Aes256Gcm>::from_slice(&bytes);
Ok(Self { key: *key })
}
}
impl SecretStore for AesSecretStore {
fn encrypt(&self, plaintext: &str) -> String {
let cipher = Aes256Gcm::new(&self.key);
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext.as_bytes())
.expect("AES-GCM encryption should not fail");
let mut combined = nonce_bytes.to_vec();
combined.extend(ciphertext);
B64.encode(combined)
}
fn decrypt(&self, ciphertext: &str) -> String {
let combined = B64
.decode(ciphertext)
.expect("invalid base64 in encrypted field");
if combined.len() < 12 {
panic!("encrypted data too short");
}
let (nonce_bytes, ct) = combined.split_at(12);
let cipher = Aes256Gcm::new(&self.key);
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ct)
.expect("AES-GCM decryption failed — wrong key or corrupted data");
String::from_utf8(plaintext).expect("decrypted data is not valid UTF-8")
}
}

View File

@@ -1,8 +1,8 @@
use client_domain::NetworkPort;
use protocol::MAX_FRAME_SIZE;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::TcpStream; use std::net::TcpStream;
use std::time::Duration; use std::time::Duration;
use client_domain::NetworkPort;
use protocol::MAX_FRAME_SIZE;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum TcpClientError { pub enum TcpClientError {
@@ -14,13 +14,14 @@ pub enum TcpClientError {
FrameTooLarge(usize), FrameTooLarge(usize),
} }
#[derive(Default)]
pub struct StdTcpClient { pub struct StdTcpClient {
stream: Option<TcpStream>, stream: Option<TcpStream>,
} }
impl StdTcpClient { impl StdTcpClient {
pub fn new() -> Self { pub fn new() -> Self {
Self { stream: None } Self::default()
} }
} }
@@ -30,7 +31,9 @@ impl NetworkPort for StdTcpClient {
fn connect(&mut self, addr: &str) -> Result<(), Self::Error> { fn connect(&mut self, addr: &str) -> Result<(), Self::Error> {
let stream = TcpStream::connect(addr).map_err(TcpClientError::Io)?; let stream = TcpStream::connect(addr).map_err(TcpClientError::Io)?;
stream.set_nonblocking(true).map_err(TcpClientError::Io)?; stream.set_nonblocking(true).map_err(TcpClientError::Io)?;
stream.set_read_timeout(Some(Duration::from_millis(10))).map_err(TcpClientError::Io)?; stream
.set_read_timeout(Some(Duration::from_millis(10)))
.map_err(TcpClientError::Io)?;
self.stream = Some(stream); self.stream = Some(stream);
Ok(()) Ok(())
} }
@@ -63,7 +66,9 @@ impl NetworkPort for StdTcpClient {
let mut payload = vec![0u8; len]; let mut payload = vec![0u8; len];
stream.set_nonblocking(false).map_err(TcpClientError::Io)?; stream.set_nonblocking(false).map_err(TcpClientError::Io)?;
stream.read_exact(&mut payload).map_err(TcpClientError::Io)?; stream
.read_exact(&mut payload)
.map_err(TcpClientError::Io)?;
stream.set_nonblocking(true).map_err(TcpClientError::Io)?; stream.set_nonblocking(true).map_err(TcpClientError::Io)?;
Ok(Some(payload)) Ok(Some(payload))

View File

@@ -9,3 +9,4 @@ protocol.workspace = true
tokio.workspace = true tokio.workspace = true
postcard.workspace = true postcard.workspace = true
thiserror.workspace = true thiserror.workspace = true
tracing.workspace = true

View File

@@ -1,12 +1,7 @@
use tokio::sync::broadcast;
use domain::{
BroadcastPort, Layout, WidgetId, WidgetState,
};
use protocol::{
ServerMessage, WidgetDescriptor, WireDisplayHint, WireLayoutNode,
encode,
};
use crate::error::TcpServerError; use crate::error::TcpServerError;
use domain::{BroadcastPort, DisplayHint, Layout, ThemeConfig, WidgetId, WidgetState};
use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireLayoutNode, WireTheme, encode};
use tokio::sync::broadcast;
pub struct TcpBroadcaster { pub struct TcpBroadcaster {
tx: broadcast::Sender<Vec<u8>>, tx: broadcast::Sender<Vec<u8>>,
@@ -34,16 +29,17 @@ impl BroadcastPort for TcpBroadcaster {
async fn push_screen_update( async fn push_screen_update(
&self, &self,
layout: &Layout, layout: &Layout,
widgets: &[(WidgetId, WidgetState)], widgets: &[(WidgetId, DisplayHint, WidgetState)],
) -> Result<(), Self::Error> { ) -> Result<(), Self::Error> {
let wire_layout: WireLayoutNode = (&layout.root).into(); let wire_layout: WireLayoutNode = (&layout.root).into();
let wire_widgets: Vec<WidgetDescriptor> = widgets.iter().map(|(id, state)| { let wire_widgets: Vec<WidgetDescriptor> = widgets
WidgetDescriptor { .iter()
.map(|(id, hint, state)| WidgetDescriptor {
id: *id, id: *id,
display_hint: WireDisplayHint::IconValue, display_hint: hint.into(),
state: state.into(), state: state.into(),
} })
}).collect(); .collect();
let msg = ServerMessage::ScreenUpdate { let msg = ServerMessage::ScreenUpdate {
layout: wire_layout, layout: wire_layout,
@@ -56,15 +52,16 @@ impl BroadcastPort for TcpBroadcaster {
async fn push_data_update( async fn push_data_update(
&self, &self,
updates: &[(WidgetId, WidgetState)], updates: &[(WidgetId, DisplayHint, WidgetState)],
) -> Result<(), Self::Error> { ) -> Result<(), Self::Error> {
let wire_widgets: Vec<WidgetDescriptor> = updates.iter().map(|(id, state)| { let wire_widgets: Vec<WidgetDescriptor> = updates
WidgetDescriptor { .iter()
.map(|(id, hint, state)| WidgetDescriptor {
id: *id, id: *id,
display_hint: WireDisplayHint::IconValue, display_hint: hint.into(),
state: state.into(), state: state.into(),
} })
}).collect(); .collect();
let msg = ServerMessage::DataUpdate { let msg = ServerMessage::DataUpdate {
widgets: wire_widgets, widgets: wire_widgets,
@@ -73,4 +70,26 @@ impl BroadcastPort for TcpBroadcaster {
let frame = encode(&msg).map_err(TcpServerError::Encode)?; let frame = encode(&msg).map_err(TcpServerError::Encode)?;
self.send_frame(frame) self.send_frame(frame)
} }
async fn push_theme_update(&self, theme: &ThemeConfig) -> Result<(), Self::Error> {
let wire_theme = domain_theme_to_wire(theme);
let msg = ServerMessage::ThemeUpdate { theme: wire_theme };
let frame = encode(&msg).map_err(TcpServerError::Encode)?;
self.send_frame(frame)
}
}
pub(crate) fn domain_theme_to_wire(t: &ThemeConfig) -> WireTheme {
let c = |c: &domain::ThemeColor| WireColor {
r: c.r,
g: c.g,
b: c.b,
};
WireTheme {
primary: c(&t.primary),
secondary: c(&t.secondary),
accent: c(&t.accent),
text: c(&t.text),
background: c(&t.background),
}
} }

View File

@@ -0,0 +1,48 @@
use domain::{ClientRegistry, ConnectedClient};
use std::net::SocketAddr;
use std::sync::Mutex;
use std::time::SystemTime;
#[derive(Default)]
pub struct ClientTracker {
clients: Mutex<Vec<ConnectedClient>>,
}
impl ClientTracker {
pub fn new() -> Self {
Self::default()
}
pub fn add(&self, addr: SocketAddr) {
let info = ConnectedClient {
addr: addr.to_string(),
connected_at: SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
};
self.clients.lock().unwrap().push(info);
}
pub fn remove(&self, addr: SocketAddr) {
let addr_str = addr.to_string();
self.clients.lock().unwrap().retain(|c| c.addr != addr_str);
}
}
impl ClientRegistry for ClientTracker {
fn add_client(&self, addr: &str, connected_at: u64) {
self.clients.lock().unwrap().push(ConnectedClient {
addr: addr.to_string(),
connected_at,
});
}
fn remove_client(&self, addr: &str) {
self.clients.lock().unwrap().retain(|c| c.addr != addr);
}
fn list_clients(&self) -> Vec<ConnectedClient> {
self.clients.lock().unwrap().clone()
}
}

View File

@@ -1,6 +1,6 @@
use tokio::sync::broadcast;
use domain::{EventPublisher, DomainEvent};
use crate::error::TcpServerError; use crate::error::TcpServerError;
use domain::{DomainEvent, EventPublisher};
use tokio::sync::broadcast;
pub struct TcpEventBus { pub struct TcpEventBus {
tx: broadcast::Sender<DomainEvent>, tx: broadcast::Sender<DomainEvent>,

View File

@@ -1,9 +1,11 @@
mod error;
mod broadcaster; mod broadcaster;
mod client_tracker;
mod error;
mod event_bus; mod event_bus;
mod server; mod server;
pub use error::TcpServerError;
pub use broadcaster::TcpBroadcaster; pub use broadcaster::TcpBroadcaster;
pub use client_tracker::ClientTracker;
pub use error::TcpServerError;
pub use event_bus::TcpEventBus; pub use event_bus::TcpEventBus;
pub use server::run_tcp_server; pub use server::run_tcp_server;

View File

@@ -1,38 +1,127 @@
use crate::broadcaster::domain_theme_to_wire;
use crate::client_tracker::ClientTracker;
use crate::error::TcpServerError;
use domain::{ConfigRepository, WidgetStateReader};
use protocol::{ServerMessage, WidgetDescriptor, WireLayoutNode, encode};
use std::sync::Arc; use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tokio::io::AsyncWriteExt; use tracing::{error, info, warn};
use crate::broadcaster::TcpBroadcaster;
use crate::error::TcpServerError;
pub async fn run_tcp_server( use crate::broadcaster::TcpBroadcaster;
pub async fn run_tcp_server<C, W>(
addr: &str, addr: &str,
broadcaster: Arc<TcpBroadcaster>, broadcaster: Arc<TcpBroadcaster>,
) -> Result<(), TcpServerError> { tracker: Arc<ClientTracker>,
config: Arc<C>,
widget_states: Arc<W>,
) -> Result<(), TcpServerError>
where
C: ConfigRepository + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send,
W: WidgetStateReader + Send + Sync + 'static,
{
let listener = TcpListener::bind(addr).await.map_err(TcpServerError::Io)?; let listener = TcpListener::bind(addr).await.map_err(TcpServerError::Io)?;
println!("TCP server listening on {addr}"); info!(addr, "TCP server listening");
loop { loop {
let (mut socket, peer) = listener.accept().await.map_err(TcpServerError::Io)?; let (mut socket, peer) = listener.accept().await.map_err(TcpServerError::Io)?;
println!("Client connected: {peer}"); info!(%peer, "client connected");
tracker.add(peer);
let tracker = tracker.clone();
let mut rx = broadcaster.subscribe(); let mut rx = broadcaster.subscribe();
let initial_frame = build_initial_frame(&*config, &*widget_states).await;
tokio::spawn(async move { tokio::spawn(async move {
if let Some(frame) = initial_frame
&& socket.write_all(&frame).await.is_err()
{
info!(%peer, "client disconnected during initial send");
tracker.remove(peer);
return;
}
loop { loop {
match rx.recv().await { match rx.recv().await {
Ok(frame) => { Ok(frame) => {
if socket.write_all(&frame).await.is_err() { if socket.write_all(&frame).await.is_err() {
println!("Client disconnected: {peer}"); info!(%peer, "client disconnected");
break; break;
} }
} }
Err(broadcast::error::RecvError::Closed) => break, Err(broadcast::error::RecvError::Closed) => break,
Err(broadcast::error::RecvError::Lagged(n)) => { Err(broadcast::error::RecvError::Lagged(n)) => {
println!("Client {peer} lagged by {n} messages"); warn!(%peer, skipped = n, "client lagged");
} }
} }
} }
tracker.remove(peer);
}); });
} }
} }
async fn build_initial_frame<C, W>(config: &C, widget_states: &W) -> Option<Vec<u8>>
where
C: ConfigRepository,
C::Error: std::fmt::Debug,
W: WidgetStateReader,
{
let layout = match config.get_layout().await {
Ok(Some(l)) => l,
Ok(None) => return None,
Err(e) => {
error!(error = ?e, "failed to fetch layout for initial send");
return None;
}
};
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
error!(error = ?e, "failed to fetch widgets for initial send");
return None;
}
};
let wire_layout: WireLayoutNode = (&layout.root).into();
let mut wire_widgets = Vec::new();
for w in &widgets {
if let Some(s) = widget_states.get_widget_state(w.id).await {
wire_widgets.push(WidgetDescriptor {
id: w.id,
display_hint: (&w.display_hint).into(),
state: (&s).into(),
});
}
}
let msg = ServerMessage::ScreenUpdate {
layout: wire_layout,
widgets: wire_widgets,
};
let mut combined = match encode(&msg) {
Ok(frame) => frame,
Err(e) => {
error!(error = %e, "failed to encode initial screen update");
return None;
}
};
if let Ok(Some(theme)) = config.get_theme().await {
let wire_theme = domain_theme_to_wire(&theme);
let theme_msg = ServerMessage::ThemeUpdate { theme: wire_theme };
match encode(&theme_msg) {
Ok(frame) => combined.extend_from_slice(&frame),
Err(e) => {
error!(error = %e, "failed to encode initial theme update");
}
}
}
Some(combined)
}

View File

@@ -0,0 +1,17 @@
use domain::ConnectedClient;
use serde::Serialize;
#[derive(Serialize)]
pub struct ClientDto {
pub addr: String,
pub connected_at: u64,
}
impl From<&ConnectedClient> for ClientDto {
fn from(c: &ConnectedClient) -> Self {
Self {
addr: c.addr.clone(),
connected_at: c.connected_at,
}
}
}

View File

@@ -1,6 +1,6 @@
use serde::{Serialize, Deserialize};
use std::time::Duration;
use domain::*; use domain::*;
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct DataSourceDto { pub struct DataSourceDto {
@@ -21,11 +21,12 @@ impl From<&DataSource> for DataSourceDto {
source_type: match ds.source_type { source_type: match ds.source_type {
DataSourceType::Weather => "weather", DataSourceType::Weather => "weather",
DataSourceType::Media => "media", DataSourceType::Media => "media",
DataSourceType::Xtb => "xtb",
DataSourceType::Rss => "rss", DataSourceType::Rss => "rss",
DataSourceType::HttpJson => "http_json", DataSourceType::HttpJson => "http_json",
DataSourceType::Webhook => "webhook", DataSourceType::Webhook => "webhook",
}.into(), }
.into(),
poll_interval_secs: ds.poll_interval.as_secs(), poll_interval_secs: ds.poll_interval.as_secs(),
url: ds.config.url.clone(), url: ds.config.url.clone(),
api_key: ds.config.api_key.clone(), api_key: ds.config.api_key.clone(),
@@ -39,7 +40,7 @@ impl DataSourceDto {
let source_type = match self.source_type.as_str() { let source_type = match self.source_type.as_str() {
"weather" => DataSourceType::Weather, "weather" => DataSourceType::Weather,
"media" => DataSourceType::Media, "media" => DataSourceType::Media,
"xtb" => DataSourceType::Xtb,
"rss" => DataSourceType::Rss, "rss" => DataSourceType::Rss,
"http_json" => DataSourceType::HttpJson, "http_json" => DataSourceType::HttpJson,
"webhook" => DataSourceType::Webhook, "webhook" => DataSourceType::Webhook,

View File

@@ -1,5 +1,5 @@
use serde::{Serialize, Deserialize};
use domain::*; use domain::*;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct SizingDto { pub struct SizingDto {
@@ -21,6 +21,10 @@ pub struct LayoutNodeDto {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub padding: Option<u8>, pub padding: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub justify_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub align_items: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub children: Option<Vec<LayoutChildDto>>, pub children: Option<Vec<LayoutChildDto>>,
} }
@@ -41,30 +45,62 @@ impl From<&LayoutNode> for LayoutNodeDto {
LayoutNode::Leaf(id) => Self { LayoutNode::Leaf(id) => Self {
node_type: "leaf".into(), node_type: "leaf".into(),
widget_id: Some(*id), widget_id: Some(*id),
direction: None, gap: None, padding: None, children: None, direction: None,
gap: None,
padding: None,
justify_content: None,
align_items: None,
children: None,
}, },
LayoutNode::Container(c) => Self { LayoutNode::Container(c) => Self {
node_type: "container".into(), node_type: "container".into(),
widget_id: None, widget_id: None,
direction: Some(match c.direction { direction: Some(
Direction::Row => "row", match c.direction {
Direction::Column => "column", Direction::Row => "row",
}.into()), Direction::Column => "column",
}
.into(),
),
gap: Some(c.gap), gap: Some(c.gap),
padding: Some(c.padding), padding: Some(c.padding),
children: Some(c.children.iter().map(|ch| LayoutChildDto { justify_content: Some(
sizing: SizingDto { match c.justify_content {
sizing_type: match ch.sizing { JustifyContent::Start => "start",
Sizing::Fixed(_) => "fixed".into(), JustifyContent::Center => "center",
Sizing::Flex(_) => "flex".into(), JustifyContent::End => "end",
}, JustifyContent::SpaceBetween => "space_between",
value: match ch.sizing { JustifyContent::SpaceEvenly => "space_evenly",
Sizing::Fixed(v) => v, }
Sizing::Flex(v) => v as u16, .into(),
}, ),
}, align_items: Some(
node: (&ch.node).into(), match c.align_items {
}).collect()), AlignItems::Start => "start",
AlignItems::Center => "center",
AlignItems::End => "end",
AlignItems::Stretch => "stretch",
}
.into(),
),
children: Some(
c.children
.iter()
.map(|ch| LayoutChildDto {
sizing: SizingDto {
sizing_type: match ch.sizing {
Sizing::Fixed(_) => "fixed".into(),
Sizing::Flex(_) => "flex".into(),
},
value: match ch.sizing {
Sizing::Fixed(v) => v,
Sizing::Flex(v) => v as u16,
},
},
node: (&ch.node).into(),
})
.collect(),
),
}, },
} }
} }
@@ -83,7 +119,9 @@ impl LayoutNodeDto {
"column" => Direction::Column, "column" => Direction::Column,
d => return Err(format!("unknown direction: {d}")), d => return Err(format!("unknown direction: {d}")),
}; };
let children = self.children.ok_or("missing children")? let children = self
.children
.ok_or("missing children")?
.into_iter() .into_iter()
.map(|ch| { .map(|ch| {
let sizing = match ch.sizing.sizing_type.as_str() { let sizing = match ch.sizing.sizing_type.as_str() {
@@ -96,10 +134,26 @@ impl LayoutNodeDto {
}) })
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
let justify_content = match self.justify_content.as_deref() {
Some("center") => JustifyContent::Center,
Some("end") => JustifyContent::End,
Some("space_between") => JustifyContent::SpaceBetween,
Some("space_evenly") => JustifyContent::SpaceEvenly,
_ => JustifyContent::Start,
};
let align_items = match self.align_items.as_deref() {
Some("center") => AlignItems::Center,
Some("end") => AlignItems::End,
Some("stretch") => AlignItems::Stretch,
_ => AlignItems::Start,
};
Ok(LayoutNode::Container(ContainerNode { Ok(LayoutNode::Container(ContainerNode {
direction, direction,
gap: self.gap.unwrap_or(0), gap: self.gap.unwrap_or(0),
padding: self.padding.unwrap_or(0), padding: self.padding.unwrap_or(0),
justify_content,
align_items,
children, children,
})) }))
} }
@@ -110,12 +164,16 @@ impl LayoutNodeDto {
impl From<&Layout> for LayoutDto { impl From<&Layout> for LayoutDto {
fn from(l: &Layout) -> Self { fn from(l: &Layout) -> Self {
Self { root: (&l.root).into() } Self {
root: (&l.root).into(),
}
} }
} }
impl LayoutDto { impl LayoutDto {
pub fn into_domain(self) -> Result<Layout, String> { pub fn into_domain(self) -> Result<Layout, String> {
Ok(Layout { root: self.root.into_domain()? }) Ok(Layout {
root: self.root.into_domain()?,
})
} }
} }

View File

@@ -1,9 +1,13 @@
pub mod widget; pub mod client;
pub mod data_source; pub mod data_source;
pub mod layout; pub mod layout;
pub mod preset; pub mod preset;
pub mod theme;
pub mod widget;
pub use widget::{KeyMappingDto, WidgetDto, CreateWidgetDto}; pub use client::ClientDto;
pub use data_source::DataSourceDto; pub use data_source::DataSourceDto;
pub use layout::{LayoutDto, LayoutNodeDto, LayoutChildDto, SizingDto}; pub use layout::{LayoutChildDto, LayoutDto, LayoutNodeDto, SizingDto};
pub use preset::{PresetDto, CreatePresetDto}; pub use preset::{CreatePresetDto, PresetDto};
pub use theme::{ColorDto, ThemeDto};
pub use widget::{CreateWidgetDto, KeyMappingDto, WidgetDto};

View File

@@ -1,6 +1,6 @@
use serde::{Serialize, Deserialize};
use domain::*;
use crate::layout::LayoutDto; use crate::layout::LayoutDto;
use domain::*;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct PresetDto { pub struct PresetDto {

View File

@@ -0,0 +1,52 @@
use domain::{ThemeColor, ThemeConfig};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct ColorDto {
pub r: u8,
pub g: u8,
pub b: u8,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ThemeDto {
pub primary: ColorDto,
pub secondary: ColorDto,
pub accent: ColorDto,
pub text: ColorDto,
pub background: ColorDto,
}
impl From<&ThemeConfig> for ThemeDto {
fn from(t: &ThemeConfig) -> Self {
let c = |c: &ThemeColor| ColorDto {
r: c.r,
g: c.g,
b: c.b,
};
Self {
primary: c(&t.primary),
secondary: c(&t.secondary),
accent: c(&t.accent),
text: c(&t.text),
background: c(&t.background),
}
}
}
impl ThemeDto {
pub fn into_domain(self) -> ThemeConfig {
let c = |c: ColorDto| ThemeColor {
r: c.r,
g: c.g,
b: c.b,
};
ThemeConfig {
primary: c(self.primary),
secondary: c(self.secondary),
accent: c(self.accent),
text: c(self.text),
background: c(self.background),
}
}
}

View File

@@ -1,5 +1,5 @@
use serde::{Serialize, Deserialize};
use domain::*; use domain::*;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct KeyMappingDto { pub struct KeyMappingDto {
@@ -28,23 +28,30 @@ pub struct CreateWidgetDto {
pub max_data_size: u16, pub max_data_size: u16,
} }
fn default_max_data_size() -> u16 { 2048 } fn default_max_data_size() -> u16 {
2048
}
impl From<&WidgetConfig> for WidgetDto { impl From<&WidgetConfig> for WidgetDto {
fn from(w: &WidgetConfig) -> Self { fn from(w: &WidgetConfig) -> Self {
Self { Self {
id: w.id, id: w.id,
name: w.name.clone(), name: w.name.clone(),
display_hint: match w.display_hint { display_hint: match w.display_hint.kind {
DisplayHint::IconValue => "icon_value", DisplayHintKind::IconValue => "icon_value",
DisplayHint::TextBlock => "text_block", DisplayHintKind::TextBlock => "text_block",
DisplayHint::KeyValue => "key_value", DisplayHintKind::KeyValue => "key_value",
}.into(), }
.into(),
data_source_id: w.data_source_id, data_source_id: w.data_source_id,
mappings: w.mappings.iter().map(|m| KeyMappingDto { mappings: w
source_path: m.source_path.clone(), .mappings
target_key: m.target_key.clone(), .iter()
}).collect(), .map(|m| KeyMappingDto {
source_path: m.source_path.clone(),
target_key: m.target_key.clone(),
})
.collect(),
max_data_size: w.max_data_size, max_data_size: w.max_data_size,
} }
} }
@@ -53,9 +60,9 @@ impl From<&WidgetConfig> for WidgetDto {
impl CreateWidgetDto { impl CreateWidgetDto {
pub fn into_domain(self) -> Result<WidgetConfig, String> { pub fn into_domain(self) -> Result<WidgetConfig, String> {
let hint = match self.display_hint.as_str() { let hint = match self.display_hint.as_str() {
"icon_value" => DisplayHint::IconValue, "icon_value" => DisplayHint::new(DisplayHintKind::IconValue),
"text_block" => DisplayHint::TextBlock, "text_block" => DisplayHint::new(DisplayHintKind::TextBlock),
"key_value" => DisplayHint::KeyValue, "key_value" => DisplayHint::new(DisplayHintKind::KeyValue),
h => return Err(format!("unknown display_hint: {h}")), h => return Err(format!("unknown display_hint: {h}")),
}; };
Ok(WidgetConfig { Ok(WidgetConfig {
@@ -63,10 +70,14 @@ impl CreateWidgetDto {
name: self.name, name: self.name,
display_hint: hint, display_hint: hint,
data_source_id: self.data_source_id, data_source_id: self.data_source_id,
mappings: self.mappings.into_iter().map(|m| KeyMapping { mappings: self
source_path: m.source_path, .mappings
target_key: m.target_key, .into_iter()
}).collect(), .map(|m| KeyMapping {
source_path: m.source_path,
target_key: m.target_key,
})
.collect(),
max_data_size: self.max_data_size, max_data_size: self.max_data_size,
}) })
} }

View File

@@ -6,6 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
domain.workspace = true domain.workspace = true
thiserror.workspace = true thiserror.workspace = true
tokio.workspace = true
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true } tokio = { workspace = true }

View File

@@ -0,0 +1,79 @@
use domain::{AuthPort, ConfigRepository, PasswordHashPort, User};
pub enum AuthError<E> {
InvalidCredentials,
RegistrationClosed,
Repository(E),
Hash(String),
}
impl<E: std::fmt::Debug> std::fmt::Display for AuthError<E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidCredentials => write!(f, "invalid credentials"),
Self::RegistrationClosed => write!(f, "registration closed (users already exist)"),
Self::Repository(e) => write!(f, "repository error: {e:?}"),
Self::Hash(e) => write!(f, "hash error: {e}"),
}
}
}
pub async fn login<C, A, H>(
config: &C,
auth: &A,
hasher: &H,
username: &str,
password: &str,
) -> Result<String, AuthError<C::Error>>
where
C: ConfigRepository,
A: AuthPort,
H: PasswordHashPort,
{
let user = config
.get_user_by_username(username)
.await
.map_err(AuthError::Repository)?
.ok_or(AuthError::InvalidCredentials)?;
let valid = hasher
.verify(password, &user.password_hash)
.await
.map_err(AuthError::Hash)?;
if !valid {
return Err(AuthError::InvalidCredentials);
}
Ok(auth.generate_token(user.id))
}
pub async fn register<C, H>(
config: &C,
hasher: &H,
username: &str,
password: &str,
) -> Result<(), AuthError<C::Error>>
where
C: ConfigRepository,
H: PasswordHashPort,
{
let count = config.count_users().await.map_err(AuthError::Repository)?;
if count > 0 {
return Err(AuthError::RegistrationClosed);
}
let hash = hasher.hash(password).await.map_err(AuthError::Hash)?;
let user = User {
id: 0,
username: username.to_string(),
password_hash: hash,
};
config
.save_user(&user)
.await
.map_err(AuthError::Repository)?;
Ok(())
}

View File

@@ -1,10 +1,8 @@
use std::fmt;
use domain::{ use domain::{
ConfigRepository, EventPublisher, DomainEvent, ConfigRepository, DataSource, DataSourceId, DataSourceValidationError, DomainEvent,
WidgetConfig, WidgetId, EventPublisher, Layout, LayoutPreset, LayoutPresetId, ThemeConfig, WidgetConfig, WidgetId,
DataSource, DataSourceId, DataSourceValidationError,
Layout, LayoutPreset, LayoutPresetId,
}; };
use std::fmt;
pub struct ConfigService<'a, C, E> { pub struct ConfigService<'a, C, E> {
config: &'a C, config: &'a C,
@@ -34,78 +32,188 @@ where
Self { config, events } Self { config, events }
} }
pub async fn create_widget(&self, widget: WidgetConfig) -> Result<(), ConfigError<C::Error, E::Error>> { pub async fn create_widget(
self.config.save_widget(&widget).await.map_err(ConfigError::Repository)?; &self,
self.events.publish(DomainEvent::WidgetCreated { id: widget.id }).await.map_err(ConfigError::Event)?; widget: WidgetConfig,
) -> Result<(), ConfigError<C::Error, E::Error>> {
self.config
.save_widget(&widget)
.await
.map_err(ConfigError::Repository)?;
self.events
.publish(DomainEvent::WidgetCreated { id: widget.id })
.await
.map_err(ConfigError::Event)?;
Ok(()) Ok(())
} }
pub async fn update_widget(&self, widget: WidgetConfig) -> Result<(), ConfigError<C::Error, E::Error>> { pub async fn update_widget(
self.config.save_widget(&widget).await.map_err(ConfigError::Repository)?; &self,
self.events.publish(DomainEvent::WidgetUpdated { id: widget.id }).await.map_err(ConfigError::Event)?; widget: WidgetConfig,
) -> Result<(), ConfigError<C::Error, E::Error>> {
self.config
.save_widget(&widget)
.await
.map_err(ConfigError::Repository)?;
self.events
.publish(DomainEvent::WidgetUpdated { id: widget.id })
.await
.map_err(ConfigError::Event)?;
Ok(()) Ok(())
} }
pub async fn delete_widget(&self, id: WidgetId) -> Result<(), ConfigError<C::Error, E::Error>> { pub async fn delete_widget(&self, id: WidgetId) -> Result<(), ConfigError<C::Error, E::Error>> {
self.config.delete_widget(id).await.map_err(ConfigError::Repository)?; self.config
self.events.publish(DomainEvent::WidgetDeleted { id }).await.map_err(ConfigError::Event)?; .delete_widget(id)
.await
.map_err(ConfigError::Repository)?;
self.events
.publish(DomainEvent::WidgetDeleted { id })
.await
.map_err(ConfigError::Event)?;
Ok(()) Ok(())
} }
pub async fn create_data_source(&self, source: DataSource) -> Result<(), ConfigError<C::Error, E::Error>> { pub async fn create_data_source(
&self,
source: DataSource,
) -> Result<(), ConfigError<C::Error, E::Error>> {
let errors = source.validate(); let errors = source.validate();
if !errors.is_empty() { if !errors.is_empty() {
return Err(ConfigError::Validation(errors)); return Err(ConfigError::Validation(errors));
} }
self.config.save_data_source(&source).await.map_err(ConfigError::Repository)?; self.config
self.events.publish(DomainEvent::DataSourceAdded { id: source.id }).await.map_err(ConfigError::Event)?; .save_data_source(&source)
.await
.map_err(ConfigError::Repository)?;
self.events
.publish(DomainEvent::DataSourceAdded { id: source.id })
.await
.map_err(ConfigError::Event)?;
Ok(()) Ok(())
} }
pub async fn update_data_source(&self, source: DataSource) -> Result<(), ConfigError<C::Error, E::Error>> { pub async fn update_data_source(
&self,
source: DataSource,
) -> Result<(), ConfigError<C::Error, E::Error>> {
let errors = source.validate(); let errors = source.validate();
if !errors.is_empty() { if !errors.is_empty() {
return Err(ConfigError::Validation(errors)); return Err(ConfigError::Validation(errors));
} }
self.config.save_data_source(&source).await.map_err(ConfigError::Repository)?; self.config
self.events.publish(DomainEvent::DataSourceUpdated { id: source.id }).await.map_err(ConfigError::Event)?; .save_data_source(&source)
.await
.map_err(ConfigError::Repository)?;
self.events
.publish(DomainEvent::DataSourceUpdated { id: source.id })
.await
.map_err(ConfigError::Event)?;
Ok(()) Ok(())
} }
pub async fn delete_data_source(&self, id: DataSourceId) -> Result<(), ConfigError<C::Error, E::Error>> { pub async fn delete_data_source(
self.config.delete_data_source(id).await.map_err(ConfigError::Repository)?; &self,
self.events.publish(DomainEvent::DataSourceRemoved { id }).await.map_err(ConfigError::Event)?; id: DataSourceId,
) -> Result<(), ConfigError<C::Error, E::Error>> {
self.config
.delete_data_source(id)
.await
.map_err(ConfigError::Repository)?;
self.events
.publish(DomainEvent::DataSourceRemoved { id })
.await
.map_err(ConfigError::Event)?;
Ok(()) Ok(())
} }
pub async fn update_layout(&self, layout: Layout) -> Result<(), ConfigError<C::Error, E::Error>> { pub async fn update_layout(
self.config.save_layout(&layout).await.map_err(ConfigError::Repository)?; &self,
self.events.publish(DomainEvent::LayoutChanged { layout }).await.map_err(ConfigError::Event)?; layout: Layout,
) -> Result<(), ConfigError<C::Error, E::Error>> {
self.config
.save_layout(&layout)
.await
.map_err(ConfigError::Repository)?;
self.events
.publish(DomainEvent::LayoutChanged { layout })
.await
.map_err(ConfigError::Event)?;
Ok(()) Ok(())
} }
pub async fn save_preset(&self, preset: LayoutPreset) -> Result<(), ConfigError<C::Error, E::Error>> { pub async fn update_theme(
self.config.save_preset(&preset).await.map_err(ConfigError::Repository)?; &self,
self.events.publish(DomainEvent::LayoutPresetSaved { id: preset.id }).await.map_err(ConfigError::Event)?; theme: ThemeConfig,
) -> Result<(), ConfigError<C::Error, E::Error>> {
self.config
.save_theme(&theme)
.await
.map_err(ConfigError::Repository)?;
self.events
.publish(DomainEvent::ThemeChanged { theme })
.await
.map_err(ConfigError::Event)?;
Ok(()) Ok(())
} }
pub async fn load_preset(&self, id: LayoutPresetId) -> Result<(), ConfigError<C::Error, E::Error>> { pub async fn save_preset(
let preset = self.config.get_preset(id).await &self,
preset: LayoutPreset,
) -> Result<(), ConfigError<C::Error, E::Error>> {
self.config
.save_preset(&preset)
.await
.map_err(ConfigError::Repository)?;
self.events
.publish(DomainEvent::LayoutPresetSaved { id: preset.id })
.await
.map_err(ConfigError::Event)?;
Ok(())
}
pub async fn load_preset(
&self,
id: LayoutPresetId,
) -> Result<(), ConfigError<C::Error, E::Error>> {
let preset = self
.config
.get_preset(id)
.await
.map_err(ConfigError::Repository)? .map_err(ConfigError::Repository)?
.ok_or(ConfigError::NotFound)?; .ok_or(ConfigError::NotFound)?;
self.events.publish(DomainEvent::LayoutPresetLoaded { id }).await.map_err(ConfigError::Event)?; self.events
.publish(DomainEvent::LayoutPresetLoaded { id })
.await
.map_err(ConfigError::Event)?;
self.config.save_layout(&preset.layout).await.map_err(ConfigError::Repository)?; self.config
self.events.publish(DomainEvent::LayoutChanged { layout: preset.layout }).await.map_err(ConfigError::Event)?; .save_layout(&preset.layout)
.await
.map_err(ConfigError::Repository)?;
self.events
.publish(DomainEvent::LayoutChanged {
layout: preset.layout,
})
.await
.map_err(ConfigError::Event)?;
Ok(()) Ok(())
} }
pub async fn delete_preset(&self, id: LayoutPresetId) -> Result<(), ConfigError<C::Error, E::Error>> { pub async fn delete_preset(
self.config.delete_preset(id).await.map_err(ConfigError::Repository)?; &self,
self.events.publish(DomainEvent::LayoutPresetDeleted { id }).await.map_err(ConfigError::Event)?; id: LayoutPresetId,
) -> Result<(), ConfigError<C::Error, E::Error>> {
self.config
.delete_preset(id)
.await
.map_err(ConfigError::Repository)?;
self.events
.publish(DomainEvent::LayoutPresetDeleted { id })
.await
.map_err(ConfigError::Event)?;
Ok(()) Ok(())
} }
} }

View File

@@ -1,23 +1,35 @@
use domain::{DataSourceId, Value, WidgetConfig, WidgetId, WidgetState, WidgetStateReader};
use std::collections::HashMap; use std::collections::HashMap;
use domain::{DataSourceId, Value, WidgetConfig, WidgetId, WidgetState}; use tokio::sync::Mutex;
pub struct DataProjection { pub struct DataProjection {
current: HashMap<WidgetId, WidgetState>, current: Mutex<HashMap<WidgetId, WidgetState>>,
}
impl Default for DataProjection {
fn default() -> Self {
Self {
current: Mutex::new(HashMap::new()),
}
}
} }
impl DataProjection { impl DataProjection {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self::default()
current: HashMap::new(),
}
} }
pub fn apply_poll_result( pub async fn get_state(&self, widget_id: WidgetId) -> Option<WidgetState> {
&mut self, self.current.lock().await.get(&widget_id).cloned()
}
pub async fn apply_poll_result(
&self,
data_source_id: DataSourceId, data_source_id: DataSourceId,
raw: &Value, raw: &Value,
widget_configs: &[WidgetConfig], widget_configs: &[WidgetConfig],
) -> Vec<(WidgetId, WidgetState)> { ) -> Vec<(WidgetId, WidgetState)> {
let mut current = self.current.lock().await;
let mut changed = Vec::new(); let mut changed = Vec::new();
for config in widget_configs { for config in widget_configs {
@@ -27,12 +39,12 @@ impl DataProjection {
let new_state = config.extract(raw); let new_state = config.extract(raw);
let is_changed = self.current let is_changed = current
.get(&config.id) .get(&config.id)
.map_or(true, |prev| *prev != new_state); .is_none_or(|prev| *prev != new_state);
if is_changed { if is_changed {
self.current.insert(config.id, new_state.clone()); current.insert(config.id, new_state.clone());
changed.push((config.id, new_state)); changed.push((config.id, new_state));
} }
} }
@@ -40,3 +52,18 @@ impl DataProjection {
changed changed
} }
} }
impl WidgetStateReader for DataProjection {
async fn get_widget_state(&self, id: WidgetId) -> Option<WidgetState> {
self.get_state(id).await
}
async fn apply_raw_data(
&self,
source_id: u16,
raw: &Value,
widgets: &[WidgetConfig],
) -> Vec<(WidgetId, WidgetState)> {
self.apply_poll_result(source_id, raw, widgets).await
}
}

View File

@@ -1,3 +1,4 @@
pub mod auth_service;
mod config_service; mod config_service;
mod data_projection; mod data_projection;

View File

@@ -1,13 +1,12 @@
mod support; mod support;
use std::time::Duration;
use domain::{
ConfigRepository, DisplayHint, DomainEvent, KeyMapping, WidgetConfig,
DataSource, DataSourceConfig, DataSourceType,
Layout, LayoutNode, ContainerNode, LayoutChild, Direction, Sizing,
LayoutPreset,
};
use application::ConfigService; use application::ConfigService;
use domain::{
AlignItems, ConfigRepository, ContainerNode, DataSource, DataSourceConfig, DataSourceType,
Direction, DisplayHint, DisplayHintKind, DomainEvent, JustifyContent, KeyMapping, Layout,
LayoutChild, LayoutNode, LayoutPreset, Sizing, WidgetConfig,
};
use std::time::Duration;
use support::{InMemoryConfigRepository, InMemoryEventPublisher}; use support::{InMemoryConfigRepository, InMemoryEventPublisher};
#[tokio::test] #[tokio::test]
@@ -19,11 +18,12 @@ async fn create_widget_persists_and_emits_event() {
let config = WidgetConfig::new( let config = WidgetConfig::new(
1, 1,
"weather".into(), "weather".into(),
DisplayHint::IconValue, DisplayHint::new(DisplayHintKind::IconValue),
1, 1,
vec![ vec![KeyMapping {
KeyMapping { source_path: "$.temp".into(), target_key: "temperature".into() }, source_path: "$.temp".into(),
], target_key: "temperature".into(),
}],
); );
service.create_widget(config).await.unwrap(); service.create_widget(config).await.unwrap();
@@ -98,9 +98,17 @@ async fn update_layout_persists_and_emits_event() {
direction: Direction::Row, direction: Direction::Row,
gap: 4, gap: 4,
padding: 2, padding: 2,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![ children: vec![
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) }, LayoutChild {
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) }, sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(1),
},
LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(2),
},
], ],
}), }),
}; };
@@ -111,7 +119,10 @@ async fn update_layout_persists_and_emits_event() {
assert_eq!(stored, Some(layout)); assert_eq!(stored, Some(layout));
assert_eq!(events.emitted().len(), 1); assert_eq!(events.emitted().len(), 1);
assert!(matches!(events.emitted()[0], DomainEvent::LayoutChanged { .. })); assert!(matches!(
events.emitted()[0],
DomainEvent::LayoutChanged { .. }
));
} }
#[tokio::test] #[tokio::test]
@@ -125,9 +136,12 @@ async fn load_preset_replaces_active_layout() {
direction: Direction::Column, direction: Direction::Column,
gap: 0, gap: 0,
padding: 0, padding: 0,
children: vec![ justify_content: JustifyContent::Start,
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(5) }, align_items: AlignItems::Stretch,
], children: vec![LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(5),
}],
}), }),
}; };
@@ -146,6 +160,9 @@ async fn load_preset_replaces_active_layout() {
let emitted = events.emitted(); let emitted = events.emitted();
assert_eq!(emitted.len(), 2); assert_eq!(emitted.len(), 2);
assert!(matches!(emitted[0], DomainEvent::LayoutPresetLoaded { id: 1 })); assert!(matches!(
emitted[0],
DomainEvent::LayoutPresetLoaded { id: 1 }
));
assert!(matches!(emitted[1], DomainEvent::LayoutChanged { .. })); assert!(matches!(emitted[1], DomainEvent::LayoutChanged { .. }));
} }

View File

@@ -1,18 +1,22 @@
use std::collections::BTreeMap;
use domain::{
DisplayHint, KeyMapping, Value, WidgetConfig, WidgetId, WidgetState,
};
use application::DataProjection; use application::DataProjection;
use domain::{DisplayHint, DisplayHintKind, KeyMapping, Value, WidgetConfig};
use std::collections::BTreeMap;
fn weather_widget() -> WidgetConfig { fn weather_widget() -> WidgetConfig {
WidgetConfig::new( WidgetConfig::new(
1, 1,
"weather".into(), "weather".into(),
DisplayHint::IconValue, DisplayHint::new(DisplayHintKind::IconValue),
10, 10,
vec![ vec![
KeyMapping { source_path: "$.temp".into(), target_key: "temperature".into() }, KeyMapping {
KeyMapping { source_path: "$.icon".into(), target_key: "icon".into() }, source_path: "$.temp".into(),
target_key: "temperature".into(),
},
KeyMapping {
source_path: "$.icon".into(),
target_key: "icon".into(),
},
], ],
) )
} }
@@ -24,58 +28,77 @@ fn weather_response(temp: f64) -> Value {
])) ]))
} }
#[test] #[tokio::test]
fn apply_poll_result_detects_new_widget_state() { async fn apply_poll_result_detects_new_widget_state() {
let mut projection = DataProjection::new(); let projection = DataProjection::new();
let widgets = vec![weather_widget()]; let widgets = vec![weather_widget()];
let changed = projection.apply_poll_result(10, &weather_response(5.4), &widgets); let changed = projection
.apply_poll_result(10, &weather_response(5.4), &widgets)
.await;
assert_eq!(changed.len(), 1); assert_eq!(changed.len(), 1);
assert_eq!(changed[0].0, 1); assert_eq!(changed[0].0, 1);
assert_eq!(changed[0].1.data.get("temperature"), Some(&Value::Number(5.4))); assert_eq!(
changed[0].1.data.get("temperature"),
Some(&Value::Number(5.4))
);
} }
#[test] #[tokio::test]
fn apply_poll_result_returns_empty_when_nothing_changed() { async fn apply_poll_result_returns_empty_when_nothing_changed() {
let mut projection = DataProjection::new(); let projection = DataProjection::new();
let widgets = vec![weather_widget()]; let widgets = vec![weather_widget()];
projection.apply_poll_result(10, &weather_response(5.4), &widgets); projection
let changed = projection.apply_poll_result(10, &weather_response(5.4), &widgets); .apply_poll_result(10, &weather_response(5.4), &widgets)
.await;
let changed = projection
.apply_poll_result(10, &weather_response(5.4), &widgets)
.await;
assert!(changed.is_empty()); assert!(changed.is_empty());
} }
#[test] #[tokio::test]
fn apply_poll_result_detects_changed_value() { async fn apply_poll_result_detects_changed_value() {
let mut projection = DataProjection::new(); let projection = DataProjection::new();
let widgets = vec![weather_widget()]; let widgets = vec![weather_widget()];
projection.apply_poll_result(10, &weather_response(5.4), &widgets); projection
let changed = projection.apply_poll_result(10, &weather_response(6.1), &widgets); .apply_poll_result(10, &weather_response(5.4), &widgets)
.await;
let changed = projection
.apply_poll_result(10, &weather_response(6.1), &widgets)
.await;
assert_eq!(changed.len(), 1); assert_eq!(changed.len(), 1);
assert_eq!(changed[0].1.data.get("temperature"), Some(&Value::Number(6.1))); assert_eq!(
changed[0].1.data.get("temperature"),
Some(&Value::Number(6.1))
);
} }
#[test] #[tokio::test]
fn apply_poll_result_only_updates_widgets_bound_to_source() { async fn apply_poll_result_only_updates_widgets_bound_to_source() {
let mut projection = DataProjection::new(); let projection = DataProjection::new();
let widgets = vec![ let widgets = vec![
weather_widget(), weather_widget(),
WidgetConfig::new( WidgetConfig::new(
2, 2,
"portfolio".into(), "portfolio".into(),
DisplayHint::KeyValue, DisplayHint::new(DisplayHintKind::KeyValue),
20, 20,
vec![ vec![KeyMapping {
KeyMapping { source_path: "$.value".into(), target_key: "amount".into() }, source_path: "$.value".into(),
], target_key: "amount".into(),
}],
), ),
]; ];
let changed = projection.apply_poll_result(10, &weather_response(5.4), &widgets); let changed = projection
.apply_poll_result(10, &weather_response(5.4), &widgets)
.await;
assert_eq!(changed.len(), 1); assert_eq!(changed.len(), 1);
assert_eq!(changed[0].0, 1); assert_eq!(changed[0].0, 1);

View File

@@ -1,15 +1,15 @@
use std::sync::Mutex;
use std::collections::HashMap;
use domain::{ use domain::{
ConfigRepository, EventPublisher, ConfigRepository, DataSource, DataSourceId, DomainEvent, EventPublisher, Layout, LayoutPreset,
DataSource, DataSourceId, Layout, LayoutPreset, LayoutPresetId, LayoutPresetId, ThemeConfig, User, WidgetConfig, WidgetId,
WidgetConfig, WidgetId, DomainEvent,
}; };
use std::collections::HashMap;
use std::sync::Mutex;
pub struct InMemoryConfigRepository { pub struct InMemoryConfigRepository {
widgets: Mutex<HashMap<WidgetId, WidgetConfig>>, widgets: Mutex<HashMap<WidgetId, WidgetConfig>>,
data_sources: Mutex<HashMap<DataSourceId, DataSource>>, data_sources: Mutex<HashMap<DataSourceId, DataSource>>,
layout: Mutex<Option<Layout>>, layout: Mutex<Option<Layout>>,
theme: Mutex<Option<ThemeConfig>>,
presets: Mutex<HashMap<LayoutPresetId, LayoutPreset>>, presets: Mutex<HashMap<LayoutPresetId, LayoutPreset>>,
} }
@@ -19,6 +19,7 @@ impl InMemoryConfigRepository {
widgets: Mutex::new(HashMap::new()), widgets: Mutex::new(HashMap::new()),
data_sources: Mutex::new(HashMap::new()), data_sources: Mutex::new(HashMap::new()),
layout: Mutex::new(None), layout: Mutex::new(None),
theme: Mutex::new(None),
presets: Mutex::new(HashMap::new()), presets: Mutex::new(HashMap::new()),
} }
} }
@@ -45,7 +46,10 @@ impl ConfigRepository for InMemoryConfigRepository {
} }
async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> { async fn save_widget(&self, config: &WidgetConfig) -> Result<(), Self::Error> {
self.widgets.lock().unwrap().insert(config.id, config.clone()); self.widgets
.lock()
.unwrap()
.insert(config.id, config.clone());
Ok(()) Ok(())
} }
@@ -59,11 +63,20 @@ impl ConfigRepository for InMemoryConfigRepository {
} }
async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error> { async fn list_data_sources(&self) -> Result<Vec<DataSource>, Self::Error> {
Ok(self.data_sources.lock().unwrap().values().cloned().collect()) Ok(self
.data_sources
.lock()
.unwrap()
.values()
.cloned()
.collect())
} }
async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> { async fn save_data_source(&self, source: &DataSource) -> Result<(), Self::Error> {
self.data_sources.lock().unwrap().insert(source.id, source.clone()); self.data_sources
.lock()
.unwrap()
.insert(source.id, source.clone());
Ok(()) Ok(())
} }
@@ -81,6 +94,15 @@ impl ConfigRepository for InMemoryConfigRepository {
Ok(()) Ok(())
} }
async fn get_theme(&self) -> Result<Option<ThemeConfig>, Self::Error> {
Ok(self.theme.lock().unwrap().clone())
}
async fn save_theme(&self, theme: &ThemeConfig) -> Result<(), Self::Error> {
*self.theme.lock().unwrap() = Some(theme.clone());
Ok(())
}
async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error> { async fn get_preset(&self, id: LayoutPresetId) -> Result<Option<LayoutPreset>, Self::Error> {
Ok(self.presets.lock().unwrap().get(&id).cloned()) Ok(self.presets.lock().unwrap().get(&id).cloned())
} }
@@ -90,7 +112,10 @@ impl ConfigRepository for InMemoryConfigRepository {
} }
async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> { async fn save_preset(&self, preset: &LayoutPreset) -> Result<(), Self::Error> {
self.presets.lock().unwrap().insert(preset.id, preset.clone()); self.presets
.lock()
.unwrap()
.insert(preset.id, preset.clone());
Ok(()) Ok(())
} }
@@ -98,6 +123,18 @@ impl ConfigRepository for InMemoryConfigRepository {
self.presets.lock().unwrap().remove(&id); self.presets.lock().unwrap().remove(&id);
Ok(()) Ok(())
} }
async fn get_user_by_username(&self, _username: &str) -> Result<Option<User>, Self::Error> {
Ok(None)
}
async fn save_user(&self, _user: &User) -> Result<(), Self::Error> {
Ok(())
}
async fn count_users(&self) -> Result<u32, Self::Error> {
Ok(0)
}
} }
pub struct InMemoryEventPublisher { pub struct InMemoryEventPublisher {

View File

@@ -5,8 +5,17 @@ edition = "2024"
[dependencies] [dependencies]
domain.workspace = true domain.workspace = true
protocol.workspace = true
application.workspace = true application.workspace = true
config-memory.workspace = true config-sqlite.workspace = true
tcp-server.workspace = true tcp-server.workspace = true
http-api.workspace = true
http-json.workspace = true
media-adapter.workspace = true
rss-adapter.workspace = true
kframe-auth.workspace = true
secret-store.workspace = true
tokio.workspace = true tokio.workspace = true
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
dotenvy.workspace = true

View File

@@ -0,0 +1,25 @@
use std::env;
pub struct ServerConfig {
pub database_url: String,
pub tcp_addr: String,
pub http_addr: String,
pub poll_interval_secs: u64,
pub spa_dir: Option<String>,
}
impl ServerConfig {
pub fn from_env() -> Self {
Self {
database_url: env::var("KFRAME_DATABASE_URL")
.unwrap_or_else(|_| "sqlite:kframe.db?mode=rwc".into()),
tcp_addr: env::var("KFRAME_TCP_ADDR").unwrap_or_else(|_| "0.0.0.0:2699".into()),
http_addr: env::var("KFRAME_HTTP_ADDR").unwrap_or_else(|_| "0.0.0.0:3000".into()),
poll_interval_secs: env::var("KFRAME_POLL_INTERVAL_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5),
spa_dir: env::var("KFRAME_SPA_DIR").ok(),
}
}
}

View File

@@ -0,0 +1,59 @@
use application::DataProjection;
use config_sqlite::SqliteConfigStore;
use domain::{BroadcastPort, ConfigRepository, DomainEvent};
use std::sync::Arc;
use tcp_server::{TcpBroadcaster, TcpEventBus};
use tracing::{error, info, warn};
pub async fn run(
event_bus: Arc<TcpEventBus>,
config: Arc<SqliteConfigStore>,
broadcaster: Arc<TcpBroadcaster>,
projection: Arc<DataProjection>,
) {
let mut rx = event_bus.subscribe();
loop {
match rx.recv().await {
Ok(DomainEvent::LayoutChanged { layout }) => {
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
error!(error = %e, "failed to fetch widgets for screen update");
continue;
}
};
let mut widget_states = Vec::new();
for w in &widgets {
if let Some(s) = projection.get_state(w.id).await {
widget_states.push((w.id, w.display_hint.clone(), s));
}
}
if let Err(e) = broadcaster
.push_screen_update(&layout, &widget_states)
.await
{
error!(error = %e, "failed to push screen update");
}
info!("layout changed, pushed screen update to clients");
}
Ok(DomainEvent::ThemeChanged { theme }) => {
if let Err(e) = broadcaster.push_theme_update(&theme).await {
error!(error = %e, "failed to push theme update");
}
info!("theme changed, pushed update to clients");
}
Ok(_) => {}
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
warn!(skipped = n, "event handler lagged, missed events");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
error!("event bus closed");
break;
}
}
}
}

View File

@@ -1,90 +1,90 @@
mod config;
mod event_handler;
mod polling;
use anyhow::Result;
use application::DataProjection;
use config_sqlite::SqliteConfigStore;
use http_api::AppState;
use kframe_auth::{Argon2Hasher, AuthConfig, JwtAuthService};
use secret_store::AesSecretStore;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use tcp_server::{ClientTracker, TcpBroadcaster, TcpEventBus, run_tcp_server};
use domain::{ use tracing::{error, info};
ConfigRepository, BroadcastPort,
WidgetConfig, DisplayHint, KeyMapping,
Layout, LayoutNode, ContainerNode, LayoutChild, Direction, Sizing,
Value, WidgetState,
};
use application::{ConfigService, DataProjection};
use config_memory::MemoryConfigStore;
use tcp_server::{TcpBroadcaster, TcpEventBus, run_tcp_server};
#[tokio::main] #[tokio::main]
async fn main() { async fn main() -> Result<()> {
let config_store = Arc::new(MemoryConfigStore::new()); dotenvy::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info,sqlx=warn".into()),
)
.init();
let cfg = config::ServerConfig::from_env();
let auth_config = AuthConfig::from_env().map_err(|e| anyhow::anyhow!(e))?;
let secrets = AesSecretStore::from_env().map_err(|e| anyhow::anyhow!(e))?;
info!(db = %cfg.database_url, "connecting to database");
let secrets = Arc::new(secrets);
let config_store =
Arc::new(SqliteConfigStore::with_secrets(&cfg.database_url, Some(secrets.clone())).await?);
let event_bus = Arc::new(TcpEventBus::new(64)); let event_bus = Arc::new(TcpEventBus::new(64));
let broadcaster = Arc::new(TcpBroadcaster::new(64)); let broadcaster = Arc::new(TcpBroadcaster::new(64));
let projection = Arc::new(DataProjection::new());
let tracker = Arc::new(ClientTracker::new());
let auth = Arc::new(JwtAuthService::new(auth_config));
let hasher = Arc::new(Argon2Hasher);
let service = ConfigService::new(config_store.as_ref(), event_bus.as_ref()); let tcp_addr = cfg.tcp_addr.clone();
let tcp_bc = broadcaster.clone();
service.create_widget(WidgetConfig::new( let tcp_tracker = tracker.clone();
1, "weather".into(), DisplayHint::IconValue, 1, let tcp_config = config_store.clone();
vec![ let tcp_proj = projection.clone();
KeyMapping { source_path: "$.temperature".into(), target_key: "value".into() },
KeyMapping { source_path: "$.icon".into(), target_key: "icon".into() },
],
)).await.unwrap();
service.create_widget(WidgetConfig::new(
2, "portfolio".into(), DisplayHint::KeyValue, 2,
vec![
KeyMapping { source_path: "$.amount".into(), target_key: "value".into() },
],
)).await.unwrap();
let layout = Layout {
root: LayoutNode::Container(ContainerNode {
direction: Direction::Row,
gap: 4,
padding: 2,
children: vec![
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) },
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(2) },
],
}),
};
service.update_layout(layout).await.unwrap();
let bc = broadcaster.clone();
tokio::spawn(async move { tokio::spawn(async move {
run_tcp_server("0.0.0.0:2699", bc).await.unwrap(); if let Err(e) = run_tcp_server(&tcp_addr, tcp_bc, tcp_tracker, tcp_config, tcp_proj).await {
error!(error = %e, "tcp server failed");
}
});
info!(addr = %cfg.tcp_addr, "TCP server started");
let http_addr = cfg.http_addr.clone();
let http_state = AppState {
config: config_store.clone(),
events: event_bus.clone(),
widget_states: projection.clone(),
broadcaster: broadcaster.clone(),
clients: tracker.clone(),
auth: auth.clone(),
hasher: hasher.clone(),
spa_dir: cfg.spa_dir,
};
tokio::spawn(async move {
if let Err(e) = http_api::serve(&http_addr, http_state).await {
error!(error = %e, "HTTP API failed");
}
});
info!(addr = %cfg.http_addr, "HTTP API started");
info!("K-Frame server running");
let ev_bus = event_bus.clone();
let ev_config = config_store.clone();
let ev_bc = broadcaster.clone();
let ev_proj = projection.clone();
tokio::spawn(async move {
event_handler::run(ev_bus, ev_config, ev_bc, ev_proj).await;
}); });
println!("Server running on :2699"); polling::run(
println!("Sending fake data every 3 seconds..."); config_store,
broadcaster,
let mut projection = DataProjection::new(); projection,
let mut counter = 0u32; cfg.poll_interval_secs,
)
loop { .await
tokio::time::sleep(Duration::from_secs(3)).await;
counter += 1;
let widgets = config_store.list_widgets().await.unwrap();
let layout = config_store.get_layout().await.unwrap();
let weather_data = Value::Object(std::collections::BTreeMap::from([
("temperature".into(), Value::String(format!("{}.{}°C", 5 + counter % 10, counter % 10))),
("icon".into(), Value::String("sunny".into())),
]));
let portfolio_data = Value::Object(std::collections::BTreeMap::from([
("amount".into(), Value::String(format!("{}.{} PLN", 100 + counter, counter % 100))),
]));
let changed_weather = projection.apply_poll_result(1, &weather_data, &widgets);
let changed_portfolio = projection.apply_poll_result(2, &portfolio_data, &widgets);
let mut all_changed: Vec<(u16, WidgetState)> = Vec::new();
all_changed.extend(changed_weather);
all_changed.extend(changed_portfolio);
if !all_changed.is_empty() {
if let Some(l) = &layout {
broadcaster.push_screen_update(l, &all_changed).await.unwrap();
}
println!("Pushed {} widget updates (tick {counter})", all_changed.len());
}
}
} }

View File

@@ -0,0 +1,173 @@
use anyhow::Result;
use application::DataProjection;
use config_sqlite::SqliteConfigStore;
use domain::{
BroadcastPort, ConfigRepository, DataSource, DataSourcePort, DataSourceType, Value, WidgetState,
};
use http_json::HttpJsonAdapter;
use media_adapter::MediaAdapter;
use rss_adapter::RssAdapter;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tcp_server::TcpBroadcaster;
use tokio::task::JoinHandle;
use tracing::{debug, info, warn};
const SOURCE_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
pub async fn run(
config: Arc<SqliteConfigStore>,
broadcaster: Arc<TcpBroadcaster>,
projection: Arc<DataProjection>,
_poll_interval_secs: u64,
) -> Result<()> {
let http_adapter = Arc::new(HttpJsonAdapter::new());
let media_adapter = Arc::new(MediaAdapter::new());
let rss_adapter = Arc::new(RssAdapter::new());
let mut running: HashMap<u16, JoinHandle<()>> = HashMap::new();
info!("polling manager started");
loop {
let sources = config
.list_data_sources()
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let current_ids: Vec<u16> = sources.iter().map(|s| s.id).collect();
running.retain(|id, handle| {
if !current_ids.contains(id) {
info!(source_id = id, "stopping poll for removed source");
handle.abort();
false
} else {
true
}
});
for source in &sources {
if source.source_type == DataSourceType::Webhook {
continue;
}
if running.contains_key(&source.id) {
continue;
}
let source_id = source.id;
let source = source.clone();
let config = config.clone();
let broadcaster = broadcaster.clone();
let projection = projection.clone();
let http = http_adapter.clone();
let media = media_adapter.clone();
let rss = rss_adapter.clone();
info!(
source_id = source.id,
name = %source.name,
interval_secs = source.poll_interval.as_secs(),
"starting poll task"
);
let handle = tokio::spawn(async move {
poll_loop(source, config, broadcaster, projection, http, media, rss).await;
});
running.insert(source_id, handle);
}
if running.is_empty() {
debug!("no pollable sources, waiting");
}
tokio::time::sleep(SOURCE_REFRESH_INTERVAL).await;
}
}
async fn poll_loop(
source: DataSource,
config: Arc<SqliteConfigStore>,
broadcaster: Arc<TcpBroadcaster>,
projection: Arc<DataProjection>,
http_adapter: Arc<HttpJsonAdapter>,
media_adapter: Arc<MediaAdapter>,
rss_adapter: Arc<RssAdapter>,
) {
let interval = source.poll_interval;
loop {
tokio::time::sleep(interval).await;
let result = match poll_source(&http_adapter, &media_adapter, &rss_adapter, &source).await {
Ok(v) => v,
Err(e) => {
warn!(source = %source.name, error = %e, "poll failed");
continue;
}
};
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
warn!(error = %e, "failed to fetch widgets");
continue;
}
};
let layout = match config.get_layout().await {
Ok(l) => l,
Err(e) => {
warn!(error = %e, "failed to fetch layout");
continue;
}
};
let changed: Vec<(u16, WidgetState)> = projection
.apply_poll_result(source.id, &result, &widgets)
.await;
if !changed.is_empty() {
let with_hints: Vec<_> = changed
.iter()
.filter_map(|(id, state)| {
let hint = widgets.iter().find(|w| w.id == *id)?.display_hint.clone();
Some((*id, hint, state.clone()))
})
.collect();
if let Some(l) = &layout
&& let Err(e) = broadcaster.push_screen_update(l, &with_hints).await
{
warn!(error = %e, "failed to push update");
}
info!(source = %source.name, count = changed.len(), "pushed widget updates");
}
}
}
async fn poll_source(
http_adapter: &HttpJsonAdapter,
media_adapter: &MediaAdapter,
rss_adapter: &RssAdapter,
source: &DataSource,
) -> Result<Value> {
match source.source_type {
DataSourceType::HttpJson | DataSourceType::Weather => http_adapter
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Media => media_adapter
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Rss => rss_adapter
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Webhook => Err(anyhow::anyhow!(
"webhook sources are push-based, not polled"
)),
}
}

View File

@@ -1,14 +1,16 @@
use std::collections::HashMap; use client_domain::{BoundingBox, Color, LayoutEngine, RenderTree, ThemeConfig};
use domain::LayoutNode; use domain::LayoutNode;
use client_domain::{BoundingBox, LayoutEngine, RenderTree};
use protocol::{ use protocol::{
ServerMessage, WidgetDescriptor, WireDisplayHint, WireWidgetState, WireLayoutNode, ServerMessage, WidgetDescriptor, WireColor, WireDisplayHint, WireLayoutNode, WireWidgetState,
}; };
use std::collections::HashMap;
pub struct ClientApp { pub struct ClientApp {
screen: BoundingBox, screen: BoundingBox,
render_tree: Option<RenderTree>, render_tree: Option<RenderTree>,
widget_states: HashMap<u16, (WireDisplayHint, WireWidgetState)>, widget_states: HashMap<u16, (WireDisplayHint, WireWidgetState)>,
theme: ThemeConfig,
theme_changed: bool,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
@@ -25,21 +27,47 @@ impl ClientApp {
screen, screen,
render_tree: None, render_tree: None,
widget_states: HashMap::new(), widget_states: HashMap::new(),
theme: ThemeConfig::default(),
theme_changed: false,
} }
} }
pub fn theme(&self) -> &ThemeConfig {
&self.theme
}
pub fn take_theme_changed(&mut self) -> bool {
let changed = self.theme_changed;
self.theme_changed = false;
changed
}
pub fn handle_message(&mut self, msg: ServerMessage) -> Vec<RepaintCommand> { pub fn handle_message(&mut self, msg: ServerMessage) -> Vec<RepaintCommand> {
match msg { match msg {
ServerMessage::ScreenUpdate { layout, widgets } => { ServerMessage::ScreenUpdate { layout, widgets } => {
self.handle_screen_update(layout, widgets) self.handle_screen_update(layout, widgets)
} }
ServerMessage::DataUpdate { widgets } => { ServerMessage::DataUpdate { widgets } => self.handle_data_update(widgets),
self.handle_data_update(widgets) ServerMessage::ThemeUpdate { theme } => self.handle_theme_update(theme),
}
ServerMessage::Heartbeat => Vec::new(), ServerMessage::Heartbeat => Vec::new(),
} }
} }
fn handle_theme_update(&mut self, wire_theme: protocol::WireTheme) -> Vec<RepaintCommand> {
self.theme = ThemeConfig {
primary: wire_color(wire_theme.primary),
secondary: wire_color(wire_theme.secondary),
accent: wire_color(wire_theme.accent),
text: wire_color(wire_theme.text),
background: wire_color(wire_theme.background),
};
self.theme_changed = true;
match &self.render_tree {
Some(tree) => self.build_repaints_for_all(tree),
None => Vec::new(),
}
}
fn handle_screen_update( fn handle_screen_update(
&mut self, &mut self,
wire_layout: WireLayoutNode, wire_layout: WireLayoutNode,
@@ -50,7 +78,8 @@ impl ClientApp {
self.widget_states.clear(); self.widget_states.clear();
for w in &widgets { for w in &widgets {
self.widget_states.insert(w.id, (w.display_hint.clone(), w.state.clone())); self.widget_states
.insert(w.id, (w.display_hint.clone(), w.state.clone()));
} }
let repaints = self.build_repaints_for_all(&new_tree); let repaints = self.build_repaints_for_all(&new_tree);
@@ -58,10 +87,7 @@ impl ClientApp {
repaints repaints
} }
fn handle_data_update( fn handle_data_update(&mut self, widgets: Vec<WidgetDescriptor>) -> Vec<RepaintCommand> {
&mut self,
widgets: Vec<WidgetDescriptor>,
) -> Vec<RepaintCommand> {
let tree = match &self.render_tree { let tree = match &self.render_tree {
Some(t) => t, Some(t) => t,
None => return Vec::new(), None => return Vec::new(),
@@ -70,9 +96,10 @@ impl ClientApp {
let mut repaints = Vec::new(); let mut repaints = Vec::new();
for w in widgets { for w in widgets {
let changed = self.widget_states let changed = self
.widget_states
.get(&w.id) .get(&w.id)
.map_or(true, |(_, prev_state)| *prev_state != w.state); .is_none_or(|(_, prev_state)| *prev_state != w.state);
if changed { if changed {
if let Some(bounds) = tree.get_widget_bounds(w.id) { if let Some(bounds) = tree.get_widget_bounds(w.id) {
@@ -108,3 +135,7 @@ impl ClientApp {
repaints repaints
} }
} }
fn wire_color(c: WireColor) -> Color {
Color(c.r, c.g, c.b)
}

View File

@@ -1,9 +1,9 @@
use client_application::{ClientApp, RepaintCommand}; use client_application::{ClientApp, RepaintCommand};
use client_domain::BoundingBox; use client_domain::BoundingBox;
use protocol::{ use protocol::{
ServerMessage, WidgetDescriptor, ServerMessage, WidgetDescriptor, WireAlignItems, WireContainerNode, WireDirection,
WireDisplayHint, WireLayoutNode, WireContainerNode, WireLayoutChild, WireDisplayHint, WireDisplayHintKind, WireJustifyContent, WireKeyValue, WireLayoutChild,
WireDirection, WireSizing, WireWidgetState, WireKeyValue, WireValue, WireLayoutNode, WireSizing, WireValue, WireWidgetState,
}; };
fn screen() -> BoundingBox { fn screen() -> BoundingBox {
@@ -13,11 +13,12 @@ fn screen() -> BoundingBox {
fn weather_descriptor(id: u16, temp: &str) -> WidgetDescriptor { fn weather_descriptor(id: u16, temp: &str) -> WidgetDescriptor {
WidgetDescriptor { WidgetDescriptor {
id, id,
display_hint: WireDisplayHint::IconValue, display_hint: WireDisplayHint::new(WireDisplayHintKind::IconValue),
state: WireWidgetState { state: WireWidgetState {
data: vec![ data: vec![WireKeyValue {
WireKeyValue { key: "temperature".into(), value: WireValue::String(temp.into()) }, key: "temperature".into(),
], value: WireValue::String(temp.into()),
}],
error: None, error: None,
}, },
} }
@@ -28,9 +29,17 @@ fn two_widget_layout() -> WireLayoutNode {
direction: WireDirection::Row, direction: WireDirection::Row,
gap: 0, gap: 0,
padding: 0, padding: 0,
justify_content: WireJustifyContent::Start,
align_items: WireAlignItems::Stretch,
children: vec![ children: vec![
WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(1) }, WireLayoutChild {
WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(2) }, sizing: WireSizing::Flex(1),
node: WireLayoutNode::Leaf(1),
},
WireLayoutChild {
sizing: WireSizing::Flex(1),
node: WireLayoutNode::Leaf(2),
},
], ],
}) })
} }
@@ -115,9 +124,17 @@ fn second_screen_update_repaints_all_widgets_with_new_layout() {
direction: WireDirection::Column, direction: WireDirection::Column,
gap: 0, gap: 0,
padding: 0, padding: 0,
justify_content: WireJustifyContent::Start,
align_items: WireAlignItems::Stretch,
children: vec![ children: vec![
WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(1) }, WireLayoutChild {
WireLayoutChild { sizing: WireSizing::Flex(1), node: WireLayoutNode::Leaf(2) }, sizing: WireSizing::Flex(1),
node: WireLayoutNode::Leaf(1),
},
WireLayoutChild {
sizing: WireSizing::Flex(1),
node: WireLayoutNode::Leaf(2),
},
], ],
}); });

View File

@@ -1,16 +1,23 @@
use std::thread;
use std::sync::mpsc;
use std::time::Duration;
use client_domain::{BoundingBox, DisplayPort, NetworkPort};
use client_application::ClientApp; use client_application::ClientApp;
use tcp_client::StdTcpClient; use client_domain::NetworkPort;
use client_domain::{BoundingBox, DisplayPort, FontMetrics, RenderEngine, ThemeConfig};
use display_terminal::TerminalDisplay; use display_terminal::TerminalDisplay;
use domain::DisplayHint;
use protocol::decode_server_message; use protocol::decode_server_message;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use tcp_client::StdTcpClient;
fn main() { fn main() {
let screen = BoundingBox::screen(240, 320); let screen = BoundingBox::screen(240, 320);
let mut app = ClientApp::new(screen); let mut app = ClientApp::new(screen);
let mut display = TerminalDisplay::new(); let mut display = TerminalDisplay::new();
let metrics = FontMetrics {
small: (6, 10),
large: (10, 20),
};
let mut engine = RenderEngine::new(metrics, ThemeConfig::default());
println!("=== K-Frame Desktop Client ==="); println!("=== K-Frame Desktop Client ===");
println!("Screen: {}x{}", screen.width, screen.height); println!("Screen: {}x{}", screen.width, screen.height);
@@ -35,12 +42,12 @@ fn main() {
} }
match net.receive() { match net.receive() {
Ok(Some(payload)) => { Ok(Some(payload)) => match decode_server_message(&payload) {
match decode_server_message(&payload) { Ok(msg) => {
Ok(msg) => { let _ = tx.send(msg); } let _ = tx.send(msg);
Err(e) => println!("[NET] Decode error: {e}"),
} }
} Err(e) => println!("[NET] Decode error: {e}"),
},
Ok(None) => { Ok(None) => {
thread::sleep(Duration::from_millis(50)); thread::sleep(Duration::from_millis(50));
} }
@@ -59,20 +66,30 @@ fn main() {
match rx.recv_timeout(Duration::from_millis(100)) { match rx.recv_timeout(Duration::from_millis(100)) {
Ok(msg) => { Ok(msg) => {
let repaints = app.handle_message(msg); let repaints = app.handle_message(msg);
if app.take_theme_changed() {
engine.set_theme(app.theme().clone());
}
if !repaints.is_empty() { if !repaints.is_empty() {
println!("\n--- Repaint ({} widgets) ---", repaints.len()); println!("\n--- Repaint ({} widgets) ---", repaints.len());
let bg = engine.theme().background;
for cmd in &repaints { for cmd in &repaints {
display.clear_region(cmd.bounds).unwrap(); display.fill_rect(cmd.bounds, bg).unwrap();
display.fill_background(cmd.bounds).unwrap();
for kv in &cmd.state.data { let hint: DisplayHint = cmd.display_hint.clone().into();
if let protocol::WireValue::String(s) = &kv.value { let data: Vec<(String, domain::Value)> = cmd
display.draw_text( .state
&format!("{}: {s}", kv.key), .data
cmd.bounds.x, cmd.bounds.y, .iter()
cmd.bounds, .map(|kv| (kv.key.clone(), kv.value.clone().into()))
).unwrap(); .collect();
}
let draw_cmds = engine.render_widget(&hint, &data, cmd.bounds, 0);
for dc in &draw_cmds {
display
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
.unwrap();
} }
} }
display.flush().unwrap(); display.flush().unwrap();

View File

@@ -0,0 +1,41 @@
use domain::{HAlign, VAlign};
#[allow(private_bounds)]
pub fn align_offset(container: u16, content: u16, align: impl Into<AlignMode>) -> u16 {
let mode = align.into();
if content >= container {
return 0;
}
let space = container - content;
match mode {
AlignMode::Start => 0,
AlignMode::Center => space / 2,
AlignMode::End => space,
}
}
enum AlignMode {
Start,
Center,
End,
}
impl From<HAlign> for AlignMode {
fn from(a: HAlign) -> Self {
match a {
HAlign::Left => AlignMode::Start,
HAlign::Center => AlignMode::Center,
HAlign::Right => AlignMode::End,
}
}
}
impl From<VAlign> for AlignMode {
fn from(a: VAlign) -> Self {
match a {
VAlign::Top => AlignMode::Start,
VAlign::Middle => AlignMode::Center,
VAlign::Bottom => AlignMode::End,
}
}
}

View File

@@ -8,10 +8,20 @@ pub struct BoundingBox {
impl BoundingBox { impl BoundingBox {
pub fn new(x: u16, y: u16, width: u16, height: u16) -> Self { pub fn new(x: u16, y: u16, width: u16, height: u16) -> Self {
Self { x, y, width, height } Self {
x,
y,
width,
height,
}
} }
pub fn screen(width: u16, height: u16) -> Self { pub fn screen(width: u16, height: u16) -> Self {
Self { x: 0, y: 0, width, height } Self {
x: 0,
y: 0,
width,
height,
}
} }
} }

View File

@@ -0,0 +1,2 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Color(pub u8, pub u8, pub u8);

View File

@@ -0,0 +1,31 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FontSize {
Small,
Large,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FontMetrics {
pub small: (u16, u16),
pub large: (u16, u16),
}
impl FontMetrics {
pub fn char_width(&self, size: FontSize) -> u16 {
match size {
FontSize::Small => self.small.0,
FontSize::Large => self.large.0,
}
}
pub fn char_height(&self, size: FontSize) -> u16 {
match size {
FontSize::Small => self.small.1,
FontSize::Large => self.large.1,
}
}
pub fn text_width(&self, text: &str, size: FontSize) -> u16 {
text.len() as u16 * self.char_width(size)
}
}

View File

@@ -1,6 +1,6 @@
use std::collections::HashMap;
use domain::{LayoutNode, ContainerNode, Direction, Sizing};
use crate::{BoundingBox, RenderTree}; use crate::{BoundingBox, RenderTree};
use domain::{ContainerNode, Direction, JustifyContent, LayoutNode, Sizing};
use std::collections::HashMap;
pub struct LayoutEngine; pub struct LayoutEngine;
@@ -11,11 +11,7 @@ impl LayoutEngine {
RenderTree { widget_bounds } RenderTree { widget_bounds }
} }
fn compute_node( fn compute_node(node: &LayoutNode, bounds: BoundingBox, out: &mut HashMap<u16, BoundingBox>) {
node: &LayoutNode,
bounds: BoundingBox,
out: &mut HashMap<u16, BoundingBox>,
) {
match node { match node {
LayoutNode::Leaf(id) => { LayoutNode::Leaf(id) => {
out.insert(*id, bounds); out.insert(*id, bounds);
@@ -48,21 +44,27 @@ impl LayoutEngine {
let total_gap = container.gap as u16 * (children.len() as u16).saturating_sub(1); let total_gap = container.gap as u16 * (children.len() as u16).saturating_sub(1);
let available = total_axis.saturating_sub(total_gap); let available = total_axis.saturating_sub(total_gap);
let fixed_total: u16 = children.iter().map(|c| match c.sizing { let fixed_total: u16 = children
Sizing::Fixed(px) => px, .iter()
Sizing::Flex(_) => 0, .map(|c| match c.sizing {
}).sum(); Sizing::Fixed(px) => px,
Sizing::Flex(_) => 0,
})
.sum();
let flex_space = available.saturating_sub(fixed_total); let flex_space = available.saturating_sub(fixed_total);
let flex_total: u16 = children.iter().map(|c| match c.sizing { let flex_total: u16 = children
Sizing::Flex(w) => w as u16, .iter()
Sizing::Fixed(_) => 0, .map(|c| match c.sizing {
}).sum(); Sizing::Flex(w) => w as u16,
Sizing::Fixed(_) => 0,
})
.sum();
let mut offset = 0u16; // Compute each child's main-axis size
let child_sizes: Vec<u16> = children
for child in children { .iter()
let child_size = match child.sizing { .map(|child| match child.sizing {
Sizing::Fixed(px) => px, Sizing::Fixed(px) => px,
Sizing::Flex(w) => { Sizing::Flex(w) => {
if flex_total > 0 { if flex_total > 0 {
@@ -71,26 +73,58 @@ impl LayoutEngine {
0 0
} }
} }
}; })
.collect();
let children_total: u16 = child_sizes.iter().sum();
let remaining = total_axis.saturating_sub(children_total + total_gap);
// Compute starting offset and gap based on justify_content
let (mut offset, justify_gap) = Self::justify(
container.justify_content,
remaining,
container.gap as u16,
children.len(),
);
for (i, child) in children.iter().enumerate() {
let child_size = child_sizes[i];
let child_bounds = if is_row { let child_bounds = if is_row {
BoundingBox::new( BoundingBox::new(inner.x + offset, inner.y, child_size, inner.height)
inner.x + offset,
inner.y,
child_size,
inner.height,
)
} else { } else {
BoundingBox::new( BoundingBox::new(inner.x, inner.y + offset, inner.width, child_size)
inner.x,
inner.y + offset,
inner.width,
child_size,
)
}; };
Self::compute_node(&child.node, child_bounds, out); Self::compute_node(&child.node, child_bounds, out);
offset += child_size + container.gap as u16; offset += child_size + justify_gap;
}
}
fn justify(
mode: JustifyContent,
remaining: u16,
explicit_gap: u16,
count: usize,
) -> (u16, u16) {
if count == 0 {
return (0, explicit_gap);
}
match mode {
JustifyContent::Start => (0, explicit_gap),
JustifyContent::Center => (remaining / 2, explicit_gap),
JustifyContent::End => (remaining, explicit_gap),
JustifyContent::SpaceBetween => {
if count <= 1 {
return (0, explicit_gap);
}
let gap = remaining / (count as u16 - 1);
(0, explicit_gap + gap)
}
JustifyContent::SpaceEvenly => {
let slots = count as u16 + 1;
let gap = remaining / slots;
(gap, explicit_gap + gap)
}
} }
} }
} }

View File

@@ -1,9 +1,26 @@
mod alignment;
mod bounding_box; mod bounding_box;
mod color;
mod font;
mod layout_engine; mod layout_engine;
mod render_tree; mod markup;
pub mod ports; pub mod ports;
mod render_engine;
mod render_tree;
mod scroll;
mod text_layout;
mod theme;
pub use alignment::align_offset;
pub use bounding_box::BoundingBox; pub use bounding_box::BoundingBox;
pub use color::Color;
pub use domain::{AlignItems, DisplayHintKind, HAlign, JustifyContent, VAlign};
pub use font::{FontMetrics, FontSize};
pub use layout_engine::LayoutEngine; pub use layout_engine::LayoutEngine;
pub use markup::{TextSpan, parse_markup};
pub use ports::{ClientConfig, DisplayPort, NetworkPort, StoragePort};
pub use render_engine::{DrawCommand, RenderEngine};
pub use render_tree::RenderTree; pub use render_tree::RenderTree;
pub use ports::{DisplayPort, NetworkPort, StoragePort, ClientConfig}; pub use scroll::ScrollState;
pub use text_layout::wrap_lines;
pub use theme::ThemeConfig;

View File

@@ -0,0 +1,70 @@
use crate::{Color, ThemeConfig};
#[derive(Debug, Clone, PartialEq)]
pub struct TextSpan {
pub text: String,
pub color: Color,
}
pub fn parse_markup(input: &str, theme: &ThemeConfig) -> Vec<TextSpan> {
if input.is_empty() {
return Vec::new();
}
let mut spans = Vec::new();
let mut current_color = theme.text;
let mut current_text = String::new();
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '{' {
let mut tag = String::new();
for c in chars.by_ref() {
if c == '}' {
break;
}
tag.push(c);
}
if let Some(new_color) = resolve_tag(&tag, theme) {
if !current_text.is_empty() {
spans.push(TextSpan {
text: current_text.clone(),
color: current_color,
});
current_text.clear();
}
current_color = new_color;
}
} else {
current_text.push(ch);
}
}
if !current_text.is_empty() {
spans.push(TextSpan {
text: current_text,
color: current_color,
});
}
spans
}
fn resolve_tag(tag: &str, theme: &ThemeConfig) -> Option<Color> {
match tag {
"/" => Some(theme.text),
"primary" => Some(theme.primary),
"secondary" => Some(theme.secondary),
"accent" => Some(theme.accent),
s if s.starts_with('#') && s.len() == 7 => parse_hex_color(&s[1..]),
_ => None,
}
}
fn parse_hex_color(hex: &str) -> Option<Color> {
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(Color(r, g, b))
}

View File

@@ -1,11 +1,18 @@
use crate::BoundingBox; use crate::{BoundingBox, Color, FontSize};
pub trait DisplayPort { pub trait DisplayPort {
type Error; type Error;
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), Self::Error>; fn draw_text_span(
fn draw_text(&mut self, text: &str, x: u16, y: u16, bounds: BoundingBox) -> Result<(), Self::Error>; &mut self,
fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), Self::Error>; text: &str,
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), Self::Error>; x: u16,
y: u16,
color: Color,
font: FontSize,
) -> Result<(), Self::Error>;
fn fill_rect(&mut self, bounds: BoundingBox, color: Color) -> Result<(), Self::Error>;
fn flush(&mut self) -> Result<(), Self::Error>; fn flush(&mut self) -> Result<(), Self::Error>;
} }

View File

@@ -4,4 +4,4 @@ mod storage;
pub use display::DisplayPort; pub use display::DisplayPort;
pub use network::NetworkPort; pub use network::NetworkPort;
pub use storage::{StoragePort, ClientConfig}; pub use storage::{ClientConfig, StoragePort};

View File

@@ -0,0 +1,195 @@
use crate::{
BoundingBox, Color, FontMetrics, FontSize, ThemeConfig, alignment::align_offset,
markup::parse_markup, text_layout::wrap_lines,
};
use domain::{DisplayHint, DisplayHintKind, HAlign, VAlign, Value};
#[derive(Debug, Clone, PartialEq)]
pub struct DrawCommand {
pub text: String,
pub x: u16,
pub y: u16,
pub color: Color,
pub font: FontSize,
}
pub struct RenderEngine {
metrics: FontMetrics,
theme: ThemeConfig,
}
impl RenderEngine {
pub fn new(metrics: FontMetrics, theme: ThemeConfig) -> Self {
Self { metrics, theme }
}
pub fn theme(&self) -> &ThemeConfig {
&self.theme
}
pub fn set_theme(&mut self, theme: ThemeConfig) {
self.theme = theme;
}
pub fn render_text(
&self,
text: &str,
bounds: BoundingBox,
h_align: HAlign,
v_align: VAlign,
) -> Vec<DrawCommand> {
let spans = parse_markup(text, &self.theme);
let plain: String = spans.iter().map(|s| s.text.as_str()).collect();
let lines = wrap_lines(&plain, bounds.width, FontSize::Small, &self.metrics);
let line_h = self.metrics.char_height(FontSize::Small);
let total_h = lines.len() as u16 * line_h;
let y_offset = align_offset(bounds.height, total_h, v_align);
let mut cmds = Vec::new();
let mut plain_pos = 0usize;
for (line_idx, line) in lines.iter().enumerate() {
let line_w = self.metrics.text_width(line, FontSize::Small);
let x_offset = align_offset(bounds.width, line_w, h_align);
let y = bounds.y + y_offset + line_idx as u16 * line_h;
let line_start = plain_pos;
let line_end = line_start + line.len();
// Map line characters back to colored spans
let mut char_pos = line_start;
while char_pos < line_end {
let (color, span_end) = self.color_at(&spans, char_pos, line_end, &plain);
let segment = &plain[char_pos..span_end];
let seg_offset =
(char_pos - line_start) as u16 * self.metrics.char_width(FontSize::Small);
cmds.push(DrawCommand {
text: segment.to_string(),
x: bounds.x + x_offset + seg_offset,
y,
color,
font: FontSize::Small,
});
char_pos = span_end;
}
plain_pos = line_end;
// Skip whitespace between lines (the space that caused the wrap)
if plain_pos < plain.len() && plain.as_bytes()[plain_pos] == b' ' {
plain_pos += 1;
}
}
cmds
}
pub fn render_widget(
&self,
hint: &DisplayHint,
data: &[(String, Value)],
bounds: BoundingBox,
scroll_offset: u16,
) -> Vec<DrawCommand> {
let text = self.format_widget(hint, data);
let mut cmds = self.render_text(&text, bounds, hint.h_align, hint.v_align);
if scroll_offset > 0 {
for cmd in &mut cmds {
cmd.y = cmd.y.saturating_sub(scroll_offset);
}
// Drop commands that scrolled above bounds
cmds.retain(|cmd| {
cmd.y + self.metrics.char_height(cmd.font) > bounds.y
&& cmd.y < bounds.y + bounds.height
});
}
cmds
}
pub fn content_height(&self, hint: &DisplayHint, data: &[(String, Value)], width: u16) -> u16 {
let text = self.format_widget(hint, data);
let plain: String = parse_markup(&text, &self.theme)
.iter()
.map(|s| s.text.as_str())
.collect();
let lines = wrap_lines(&plain, width, FontSize::Small, &self.metrics);
lines.len() as u16 * self.metrics.char_height(FontSize::Small)
}
fn format_widget(&self, hint: &DisplayHint, data: &[(String, Value)]) -> String {
match hint.kind {
DisplayHintKind::TextBlock => data
.iter()
.filter_map(|(_, v)| value_to_string(v))
.collect::<Vec<_>>()
.join("\n"),
DisplayHintKind::KeyValue => data
.iter()
.filter_map(|(k, v)| {
let val = value_to_string(v)?;
Some(format!("{{primary}}{k}{{/}}: {val}"))
})
.collect::<Vec<_>>()
.join("\n"),
DisplayHintKind::IconValue => {
let mut parts = Vec::new();
for (k, v) in data {
if k == "icon"
&& let Some(s) = value_to_string(v)
{
parts.push(s);
}
}
for (k, v) in data {
if k != "icon"
&& let Some(s) = value_to_string(v)
{
parts.push(s);
}
}
parts.join(" ")
}
}
}
fn color_at(
&self,
spans: &[crate::markup::TextSpan],
pos: usize,
line_end: usize,
_plain: &str,
) -> (Color, usize) {
let mut offset = 0usize;
for span in spans {
let span_end = offset + span.text.len();
if pos >= offset && pos < span_end {
let end = span_end.min(line_end);
return (span.color, end);
}
offset = span_end;
}
(self.theme.text, line_end)
}
}
fn value_to_string(v: &Value) -> Option<String> {
match v {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
Value::Null => None,
Value::Array(arr) => {
let items: Vec<String> = arr.iter().filter_map(value_to_string).collect();
if items.is_empty() {
None
} else {
Some(items.join(", "))
}
}
Value::Object(_) => None,
}
}

View File

@@ -1,6 +1,6 @@
use std::collections::HashMap;
use domain::WidgetId;
use crate::BoundingBox; use crate::BoundingBox;
use domain::WidgetId;
use std::collections::HashMap;
pub struct RenderTree { pub struct RenderTree {
pub widget_bounds: HashMap<WidgetId, BoundingBox>, pub widget_bounds: HashMap<WidgetId, BoundingBox>,

View File

@@ -0,0 +1,82 @@
use std::time::Duration;
const PAUSE_DURATION: Duration = Duration::from_secs(2);
const SCROLL_SPEED_PX_PER_SEC: f32 = 30.0;
#[derive(Debug)]
pub struct ScrollState {
overflow: u16,
offset: f32,
direction: ScrollDirection,
pause_elapsed: Duration,
pausing: bool,
}
#[derive(Debug, PartialEq)]
enum ScrollDirection {
Forward,
Backward,
}
impl ScrollState {
pub fn new(container: u16, content: u16) -> Self {
Self {
overflow: content.saturating_sub(container),
offset: 0.0,
direction: ScrollDirection::Forward,
pause_elapsed: Duration::ZERO,
pausing: true,
}
}
pub fn is_active(&self) -> bool {
self.overflow > 0
}
pub fn offset(&self) -> u16 {
self.offset as u16
}
pub fn reset(&mut self, container: u16, content: u16) {
*self = Self::new(container, content);
}
pub fn tick(&mut self, elapsed: Duration) -> bool {
if !self.is_active() {
return false;
}
if self.pausing {
self.pause_elapsed += elapsed;
if self.pause_elapsed < PAUSE_DURATION {
return false;
}
self.pausing = false;
self.pause_elapsed = Duration::ZERO;
}
let prev_offset = self.offset as u16;
let delta = SCROLL_SPEED_PX_PER_SEC * elapsed.as_secs_f32();
match self.direction {
ScrollDirection::Forward => {
self.offset += delta;
if self.offset >= self.overflow as f32 {
self.offset = self.overflow as f32;
self.direction = ScrollDirection::Backward;
self.pausing = true;
}
}
ScrollDirection::Backward => {
self.offset -= delta;
if self.offset <= 0.0 {
self.offset = 0.0;
self.direction = ScrollDirection::Forward;
self.pausing = true;
}
}
}
self.offset as u16 != prev_offset
}
}

View File

@@ -0,0 +1,126 @@
use crate::{FontMetrics, FontSize};
pub fn wrap_lines<'a>(
text: &'a str,
max_width: u16,
font: FontSize,
metrics: &FontMetrics,
) -> Vec<&'a str> {
if text.is_empty() {
return Vec::new();
}
let char_w = metrics.char_width(font);
let max_chars = (max_width / char_w) as usize;
if max_chars == 0 {
return Vec::new();
}
let mut lines = Vec::new();
let mut line_start = 0;
let mut line_end = 0;
for word_start in WordStarts::new(text) {
let word_end = text[word_start..]
.find(' ')
.map_or(text.len(), |i| word_start + i);
if line_start == line_end {
// First word on this line
if word_end - word_start > max_chars {
// Word itself doesn't fit — character break
let mut pos = word_start;
while pos < word_end {
let end = (pos + max_chars).min(word_end);
lines.push(&text[pos..end]);
pos = end;
}
line_start = word_end;
line_end = word_end;
// Skip trailing space
if line_start < text.len() && text.as_bytes()[line_start] == b' ' {
line_start += 1;
line_end = line_start;
}
} else {
line_end = word_end;
}
} else {
// Adding word to existing line: line_end + " " + word
let new_len = word_end - line_start;
if new_len <= max_chars {
line_end = word_end;
} else {
// Flush current line, start new one with this word
lines.push(&text[line_start..line_end]);
if word_end - word_start > max_chars {
let mut pos = word_start;
while pos < word_end {
let end = (pos + max_chars).min(word_end);
lines.push(&text[pos..end]);
pos = end;
}
line_start = word_end;
line_end = word_end;
if line_start < text.len() && text.as_bytes()[line_start] == b' ' {
line_start += 1;
line_end = line_start;
}
} else {
line_start = word_start;
line_end = word_end;
}
}
}
}
if line_end > line_start {
lines.push(&text[line_start..line_end]);
}
lines
}
struct WordStarts<'a> {
text: &'a str,
pos: usize,
started: bool,
}
impl<'a> WordStarts<'a> {
fn new(text: &'a str) -> Self {
Self {
text,
pos: 0,
started: false,
}
}
}
impl Iterator for WordStarts<'_> {
type Item = usize;
fn next(&mut self) -> Option<usize> {
if !self.started {
self.started = true;
if self.pos < self.text.len() {
return Some(0);
}
return None;
}
while self.pos < self.text.len() {
if self.text.as_bytes()[self.pos] == b' ' {
self.pos += 1;
if self.pos < self.text.len() {
let start = self.pos;
return Some(start);
}
} else {
self.pos += 1;
}
}
None
}
}

View File

@@ -0,0 +1,22 @@
use crate::Color;
#[derive(Debug, Clone, PartialEq)]
pub struct ThemeConfig {
pub primary: Color,
pub secondary: Color,
pub accent: Color,
pub text: Color,
pub background: Color,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self {
primary: Color(0x00, 0x7A, 0xCC),
secondary: Color(0x88, 0x88, 0x88),
accent: Color(0xE9, 0x45, 0x60),
text: Color(0xFF, 0xFF, 0xFF),
background: Color(0x00, 0x00, 0x00),
}
}
}

View File

@@ -0,0 +1,47 @@
use client_domain::{BoundingBox, HAlign, VAlign, align_offset};
#[test]
fn halign_left_is_zero_offset() {
let offset = align_offset(100, 60, HAlign::Left);
assert_eq!(offset, 0);
}
#[test]
fn halign_center_centers_content() {
// 100px container, 60px content → 20px offset
let offset = align_offset(100, 60, HAlign::Center);
assert_eq!(offset, 20);
}
#[test]
fn halign_right_pushes_to_end() {
// 100px container, 60px content → 40px offset
let offset = align_offset(100, 60, HAlign::Right);
assert_eq!(offset, 40);
}
#[test]
fn valign_top_is_zero_offset() {
let offset = align_offset(200, 30, VAlign::Top);
assert_eq!(offset, 0);
}
#[test]
fn valign_middle_centers_content() {
// 200px container, 30px content → 85px offset
let offset = align_offset(200, 30, VAlign::Middle);
assert_eq!(offset, 85);
}
#[test]
fn valign_bottom_pushes_to_end() {
// 200px container, 30px content → 170px offset
let offset = align_offset(200, 30, VAlign::Bottom);
assert_eq!(offset, 170);
}
#[test]
fn content_larger_than_container_clamps_to_zero() {
let offset = align_offset(50, 100, HAlign::Center);
assert_eq!(offset, 0);
}

View File

@@ -1,7 +1,7 @@
use domain::{
LayoutNode, ContainerNode, LayoutChild, Direction, Sizing,
};
use client_domain::{BoundingBox, LayoutEngine, RenderTree}; use client_domain::{BoundingBox, LayoutEngine, RenderTree};
use domain::{
AlignItems, ContainerNode, Direction, JustifyContent, LayoutChild, LayoutNode, Sizing,
};
fn screen() -> BoundingBox { fn screen() -> BoundingBox {
BoundingBox::screen(240, 320) BoundingBox::screen(240, 320)
@@ -26,6 +26,8 @@ fn row(children: Vec<LayoutChild>) -> LayoutNode {
direction: Direction::Row, direction: Direction::Row,
gap: 0, gap: 0,
padding: 0, padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children, children,
}) })
} }
@@ -35,6 +37,8 @@ fn column(children: Vec<LayoutChild>) -> LayoutNode {
direction: Direction::Column, direction: Direction::Column,
gap: 0, gap: 0,
padding: 0, padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children, children,
}) })
} }
@@ -44,6 +48,8 @@ fn row_with_gap(gap: u8, children: Vec<LayoutChild>) -> LayoutNode {
direction: Direction::Row, direction: Direction::Row,
gap, gap,
padding: 0, padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children, children,
}) })
} }
@@ -53,6 +59,8 @@ fn row_with_padding(padding: u8, children: Vec<LayoutChild>) -> LayoutNode {
direction: Direction::Row, direction: Direction::Row,
gap: 0, gap: 0,
padding, padding,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children, children,
}) })
} }
@@ -73,9 +81,18 @@ fn row_splits_width_among_equal_flex_children() {
let layout = row(vec![leaf(1), leaf(2), leaf(3)]); let layout = row(vec![leaf(1), leaf(2), leaf(3)]);
let tree = LayoutEngine::compute(&layout, screen()); let tree = LayoutEngine::compute(&layout, screen());
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 80, 320))); assert_eq!(
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(80, 0, 80, 320))); tree.get_widget_bounds(1),
assert_eq!(tree.get_widget_bounds(3), Some(&BoundingBox::new(160, 0, 80, 320))); Some(&BoundingBox::new(0, 0, 80, 320))
);
assert_eq!(
tree.get_widget_bounds(2),
Some(&BoundingBox::new(80, 0, 80, 320))
);
assert_eq!(
tree.get_widget_bounds(3),
Some(&BoundingBox::new(160, 0, 80, 320))
);
} }
#[test] #[test]
@@ -83,8 +100,14 @@ fn column_splits_height_among_equal_flex_children() {
let layout = column(vec![leaf(1), leaf(2)]); let layout = column(vec![leaf(1), leaf(2)]);
let tree = LayoutEngine::compute(&layout, screen()); let tree = LayoutEngine::compute(&layout, screen());
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 240, 160))); assert_eq!(
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(0, 160, 240, 160))); tree.get_widget_bounds(1),
Some(&BoundingBox::new(0, 0, 240, 160))
);
assert_eq!(
tree.get_widget_bounds(2),
Some(&BoundingBox::new(0, 160, 240, 160))
);
} }
#[test] #[test]
@@ -92,8 +115,14 @@ fn fixed_and_flex_children_coexist() {
let layout = row(vec![leaf_fixed(1, 40), leaf(2)]); let layout = row(vec![leaf_fixed(1, 40), leaf(2)]);
let tree = LayoutEngine::compute(&layout, screen()); let tree = LayoutEngine::compute(&layout, screen());
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 40, 320))); assert_eq!(
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(40, 0, 200, 320))); tree.get_widget_bounds(1),
Some(&BoundingBox::new(0, 0, 40, 320))
);
assert_eq!(
tree.get_widget_bounds(2),
Some(&BoundingBox::new(40, 0, 200, 320))
);
} }
#[test] #[test]
@@ -102,8 +131,14 @@ fn gap_is_subtracted_before_distributing_space() {
let layout = row_with_gap(10, vec![leaf(1), leaf(2)]); let layout = row_with_gap(10, vec![leaf(1), leaf(2)]);
let tree = LayoutEngine::compute(&layout, screen()); let tree = LayoutEngine::compute(&layout, screen());
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 115, 320))); assert_eq!(
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(125, 0, 115, 320))); tree.get_widget_bounds(1),
Some(&BoundingBox::new(0, 0, 115, 320))
);
assert_eq!(
tree.get_widget_bounds(2),
Some(&BoundingBox::new(125, 0, 115, 320))
);
} }
#[test] #[test]
@@ -112,7 +147,10 @@ fn padding_insets_available_area() {
let layout = row_with_padding(10, vec![leaf(1)]); let layout = row_with_padding(10, vec![leaf(1)]);
let tree = LayoutEngine::compute(&layout, screen()); let tree = LayoutEngine::compute(&layout, screen());
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(10, 10, 220, 300))); assert_eq!(
tree.get_widget_bounds(1),
Some(&BoundingBox::new(10, 10, 220, 300))
);
} }
#[test] #[test]
@@ -125,9 +163,18 @@ fn nested_containers_compute_correctly() {
let layout = row(vec![leaf(1), inner_col]); let layout = row(vec![leaf(1), inner_col]);
let tree = LayoutEngine::compute(&layout, screen()); let tree = LayoutEngine::compute(&layout, screen());
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 120, 320))); assert_eq!(
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(120, 0, 120, 160))); tree.get_widget_bounds(1),
assert_eq!(tree.get_widget_bounds(3), Some(&BoundingBox::new(120, 160, 120, 160))); Some(&BoundingBox::new(0, 0, 120, 320))
);
assert_eq!(
tree.get_widget_bounds(2),
Some(&BoundingBox::new(120, 0, 120, 160))
);
assert_eq!(
tree.get_widget_bounds(3),
Some(&BoundingBox::new(120, 160, 120, 160))
);
} }
#[test] #[test]
@@ -137,15 +184,165 @@ fn weighted_flex_distributes_proportionally() {
direction: Direction::Row, direction: Direction::Row,
gap: 0, gap: 0,
padding: 0, padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![ children: vec![
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(1) }, LayoutChild {
LayoutChild { sizing: Sizing::Flex(2), node: LayoutNode::Leaf(2) }, sizing: Sizing::Flex(1),
LayoutChild { sizing: Sizing::Flex(1), node: LayoutNode::Leaf(3) }, node: LayoutNode::Leaf(1),
},
LayoutChild {
sizing: Sizing::Flex(2),
node: LayoutNode::Leaf(2),
},
LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(3),
},
], ],
}); });
let tree = LayoutEngine::compute(&layout, screen()); let tree = LayoutEngine::compute(&layout, screen());
assert_eq!(tree.get_widget_bounds(1), Some(&BoundingBox::new(0, 0, 60, 320))); assert_eq!(
assert_eq!(tree.get_widget_bounds(2), Some(&BoundingBox::new(60, 0, 120, 320))); tree.get_widget_bounds(1),
assert_eq!(tree.get_widget_bounds(3), Some(&BoundingBox::new(180, 0, 60, 320))); Some(&BoundingBox::new(0, 0, 60, 320))
);
assert_eq!(
tree.get_widget_bounds(2),
Some(&BoundingBox::new(60, 0, 120, 320))
);
assert_eq!(
tree.get_widget_bounds(3),
Some(&BoundingBox::new(180, 0, 60, 320))
);
}
// --- JustifyContent tests ---
#[test]
fn justify_center_centers_fixed_children_on_main_axis() {
// Row 240px, two fixed 40px children → 160px remaining, offset = 80
let layout = LayoutNode::Container(ContainerNode {
direction: Direction::Row,
gap: 0,
padding: 0,
justify_content: JustifyContent::Center,
align_items: AlignItems::Stretch,
children: vec![leaf_fixed(1, 40), leaf_fixed(2, 40)],
});
let tree = LayoutEngine::compute(&layout, screen());
assert_eq!(
tree.get_widget_bounds(1),
Some(&BoundingBox::new(80, 0, 40, 320))
);
assert_eq!(
tree.get_widget_bounds(2),
Some(&BoundingBox::new(120, 0, 40, 320))
);
}
#[test]
fn justify_end_pushes_to_end() {
// Row 240px, one fixed 40px → offset = 200
let layout = LayoutNode::Container(ContainerNode {
direction: Direction::Row,
gap: 0,
padding: 0,
justify_content: JustifyContent::End,
align_items: AlignItems::Stretch,
children: vec![leaf_fixed(1, 40)],
});
let tree = LayoutEngine::compute(&layout, screen());
assert_eq!(
tree.get_widget_bounds(1),
Some(&BoundingBox::new(200, 0, 40, 320))
);
}
#[test]
fn justify_space_between_distributes_gaps() {
// Row 240px, three fixed 40px → 120px used, 120px remaining, 2 gaps of 60px
let layout = LayoutNode::Container(ContainerNode {
direction: Direction::Row,
gap: 0,
padding: 0,
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Stretch,
children: vec![leaf_fixed(1, 40), leaf_fixed(2, 40), leaf_fixed(3, 40)],
});
let tree = LayoutEngine::compute(&layout, screen());
assert_eq!(
tree.get_widget_bounds(1),
Some(&BoundingBox::new(0, 0, 40, 320))
);
assert_eq!(
tree.get_widget_bounds(2),
Some(&BoundingBox::new(100, 0, 40, 320))
);
assert_eq!(
tree.get_widget_bounds(3),
Some(&BoundingBox::new(200, 0, 40, 320))
);
}
#[test]
fn justify_space_evenly_distributes_with_edges() {
// Row 240px, two fixed 40px → 80px used, 160px remaining, 3 slots of 53px (int div)
let layout = LayoutNode::Container(ContainerNode {
direction: Direction::Row,
gap: 0,
padding: 0,
justify_content: JustifyContent::SpaceEvenly,
align_items: AlignItems::Stretch,
children: vec![leaf_fixed(1, 40), leaf_fixed(2, 40)],
});
let tree = LayoutEngine::compute(&layout, screen());
// 160 / 3 = 53px per slot
assert_eq!(
tree.get_widget_bounds(1),
Some(&BoundingBox::new(53, 0, 40, 320))
);
assert_eq!(
tree.get_widget_bounds(2),
Some(&BoundingBox::new(146, 0, 40, 320))
);
}
// --- AlignItems tests ---
#[test]
fn align_items_center_centers_on_cross_axis() {
// Row 240×320, fixed child 40px wide. AlignItems::Center → child centered vertically
// Cross axis = 320, child height stays 320 for Stretch.
// With Center, child gets its natural size. For a leaf, "natural" = full cross.
// Actually: fixed children have explicit main-axis size. Cross-axis with Center
// should give the child the full cross-axis (we don't know natural cross size for leaves).
// So for leaves, Center behaves like Stretch. This test verifies columns:
// Column 240×320, fixed child 100px tall. AlignItems::Center → centered on 240px width.
// But again, leaf has no natural width. For now: non-Stretch gives child full cross-axis.
// Let's test with a nested container that has known size instead.
// Actually, the simplest useful behavior: AlignItems on a row affects child y-position.
// For a fixed-height child in a column, Center means child doesn't stretch to full width.
// But we have no "natural width" concept for leaves. Let's just verify Stretch = full cross
// and Center = full cross (since we can't shrink without natural size).
// This is a design limitation we can revisit.
let layout = LayoutNode::Container(ContainerNode {
direction: Direction::Row,
gap: 0,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![leaf_fixed(1, 40)],
});
let tree = LayoutEngine::compute(&layout, screen());
// Stretch: child gets full cross-axis height
assert_eq!(
tree.get_widget_bounds(1),
Some(&BoundingBox::new(0, 0, 40, 320))
);
} }

View File

@@ -0,0 +1,114 @@
use client_domain::{Color, TextSpan, ThemeConfig, parse_markup};
fn theme() -> ThemeConfig {
ThemeConfig::default()
}
#[test]
fn plain_text_produces_single_span() {
let spans = parse_markup("hello world", &theme());
assert_eq!(
spans,
vec![TextSpan {
text: "hello world".into(),
color: theme().text
},]
);
}
#[test]
fn hex_color_span() {
let spans = parse_markup("temp: {#FF0000}72°F{/}", &theme());
assert_eq!(
spans,
vec![
TextSpan {
text: "temp: ".into(),
color: theme().text
},
TextSpan {
text: "72°F".into(),
color: Color(0xFF, 0, 0)
},
]
);
}
#[test]
fn theme_color_spans() {
let t = theme();
let spans = parse_markup("{primary}hello{/} {accent}world{/}", &t);
assert_eq!(
spans,
vec![
TextSpan {
text: "hello".into(),
color: t.primary
},
TextSpan {
text: " ".into(),
color: t.text
},
TextSpan {
text: "world".into(),
color: t.accent
},
]
);
}
#[test]
fn reset_returns_to_text_color() {
let t = theme();
let spans = parse_markup("{accent}hi{/}bye", &t);
assert_eq!(
spans,
vec![
TextSpan {
text: "hi".into(),
color: t.accent
},
TextSpan {
text: "bye".into(),
color: t.text
},
]
);
}
#[test]
fn empty_input_produces_no_spans() {
let spans = parse_markup("", &theme());
assert_eq!(spans, Vec::<TextSpan>::new());
}
#[test]
fn adjacent_color_spans_no_text_between() {
let t = theme();
let spans = parse_markup("{primary}a{secondary}b{/}", &t);
assert_eq!(
spans,
vec![
TextSpan {
text: "a".into(),
color: t.primary
},
TextSpan {
text: "b".into(),
color: t.secondary
},
]
);
}
#[test]
fn unknown_tag_treated_as_literal() {
let spans = parse_markup("{unknown}text", &theme());
assert_eq!(
spans,
vec![TextSpan {
text: "text".into(),
color: theme().text
},]
);
}

View File

@@ -0,0 +1,83 @@
use client_domain::{
BoundingBox, Color, DrawCommand, FontMetrics, FontSize, HAlign, RenderEngine, ThemeConfig,
VAlign,
};
fn metrics() -> FontMetrics {
FontMetrics {
small: (6, 10),
large: (10, 20),
}
}
fn theme() -> ThemeConfig {
ThemeConfig::default()
}
fn bounds(w: u16, h: u16) -> BoundingBox {
BoundingBox::new(0, 0, w, h)
}
#[test]
fn textblock_renders_plain_text() {
let engine = RenderEngine::new(metrics(), theme());
let cmds = engine.render_text("hello", bounds(100, 40), HAlign::Left, VAlign::Top);
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].text, "hello");
assert_eq!(cmds[0].x, 0);
assert_eq!(cmds[0].y, 0);
assert_eq!(cmds[0].color, theme().text);
assert_eq!(cmds[0].font, FontSize::Small);
}
#[test]
fn text_centered_horizontally() {
let engine = RenderEngine::new(metrics(), theme());
// "hi" = 12px, bounds = 100px → offset = 44
let cmds = engine.render_text("hi", bounds(100, 40), HAlign::Center, VAlign::Top);
assert_eq!(cmds[0].x, 44);
}
#[test]
fn text_centered_vertically() {
let engine = RenderEngine::new(metrics(), theme());
// 1 line = 10px height, bounds = 40px → offset = 15
let cmds = engine.render_text("hi", bounds(100, 40), HAlign::Left, VAlign::Middle);
assert_eq!(cmds[0].y, 15);
}
#[test]
fn text_wraps_and_stacks_lines() {
let engine = RenderEngine::new(metrics(), theme());
// "hello world" at 40px wide → "hello" + "world", each at 6x10
let cmds = engine.render_text("hello world", bounds(40, 100), HAlign::Left, VAlign::Top);
assert_eq!(cmds.len(), 2);
assert_eq!(cmds[0].text, "hello");
assert_eq!(cmds[0].y, 0);
assert_eq!(cmds[1].text, "world");
assert_eq!(cmds[1].y, 10);
}
#[test]
fn colored_markup_produces_colored_spans() {
let engine = RenderEngine::new(metrics(), theme());
let cmds = engine.render_text("{accent}hi{/}", bounds(100, 40), HAlign::Left, VAlign::Top);
assert_eq!(cmds.len(), 1);
assert_eq!(cmds[0].text, "hi");
assert_eq!(cmds[0].color, theme().accent);
}
#[test]
fn bounds_offset_applied() {
let engine = RenderEngine::new(metrics(), theme());
let b = BoundingBox::new(10, 20, 100, 40);
let cmds = engine.render_text("hi", b, HAlign::Left, VAlign::Top);
assert_eq!(cmds[0].x, 10);
assert_eq!(cmds[0].y, 20);
}

Some files were not shown because too many files have changed in this diff Show More