Compare commits

..

25 Commits

Author SHA1 Message Date
fa097771d4 arch: push wire types out of ClientApp, extract event_service, cleanup dead code
- ClientApp stores domain types, RepaintCommand carries DisplayHint + Vec<(String,Value)>
- adapters no longer convert Wire→Domain (eliminated duplication in esp32 + desktop)
- event_service in application layer handles LayoutChanged/WebhookDataReceived/ThemeChanged
- bootstrap event_handler reduced to 10-line dispatcher
- polling_service reuses event_service::apply_and_broadcast (deduplicated broadcast pattern)
- AppState.config_service() replaces 11 inline ConfigService::new() calls
- delete unused poll_interval_secs parameter chain
- delete unused StoragePort/ClientConfig (zero implementations)
2026-06-19 18:30:14 +02:00
7001b5e911 arch: split ConfigRepository, extract polling, consolidate conversions, decouple protocol
- Value↔JSON: From impls on domain Value behind `json` feature, delete 4 duplicate converters
- ConfigRepository split into ConfigRepository (12), UserRepository (3), WidgetStateCache (2)
- polling orchestration moved from bootstrap to application::polling_service
- WidgetRenderer in client-domain owns scroll/cache, both clients use it
- network loop consolidated into client-application::run_connection_loop
- protocol crate drops domain dep, Wire↔Domain conversions move to adapters
2026-06-19 18:12:50 +02:00
1c854d127f fix bottom scroll artifacts, slow scroll for readability 2026-06-19 13:35:46 +02:00
838e29702a fix scroll artifacts at widget edges, disable esp-mesh 2026-06-19 13:32:44 +02:00
5bcf4c4e0d strip unused esp32 deps, fix render loop power waste 2026-06-19 13:22:12 +02:00
27c1fe3f37 optimize esp32 release binary size: 1.7MB -> 1.1MB 2026-06-19 13:13:23 +02:00
b964801765 remove all modals, inline editing, live layout preview, clock preview
all Dialog/AlertDialog removed from widgets, data-sources, presets,
layout-builder pages. replaced with inline card expansion for
edit/create and inline confirm bars for delete.

data source form: live clock format preview with 1s tick, timezone
validation against Intl.supportedValuesOf.

layout preview: fetches live widget data via useWidgetPreview, renders
formatted content based on display_hint kind instead of widget names.
2026-06-19 13:08:00 +02:00
13497dd53c state recovery, polling optimizations, error rendering
widget states cached to SQLite, loaded on startup to seed DataProjection
so server restart preserves last-known data for reconnecting clients.

polling: first poll runs immediately, widget list cached per-task with
30s refresh, static text polled once inline instead of looping.

poll failures propagate WidgetError::SourceUnavailable to clients.
render engine prepends [offline] prefix in accent color, stale data
preserved below.
2026-06-19 12:56:12 +02:00
8b1dac9669 update README: wiring table, new features, data-generators in arch diagram 2026-06-19 12:37:30 +02:00
a6152c9a9a update README.md to include clock and static text as data sources, and add widget alignment and connection indicator features 2026-06-19 12:35:10 +02:00
455d5da901 webhook through event system, extract data-generators adapter
webhook route now emits WebhookDataReceived event instead of directly
mutating DataProjection and broadcasting. event_handler applies data
and pushes incremental DataUpdate.

clock/static_text generators extracted to data-generators crate behind
DataSourcePort. chrono removed from bootstrap. polling adapters grouped
into Adapters struct.
2026-06-19 12:33:42 +02:00
437056cfc4 clean up 2026-06-19 11:32:49 +02:00
a51d22649a internal data sources (clock, static text), connection indicator, rendering fixes
DataSourceConfig refactored to enum: External/Clock/StaticText. Clock
generates formatted time via chrono, static text emits configured string.

ESP32: connection status indicator (green/red dot bottom-right), per-widget
clear before redraw, RenderEvent enum for local + server messages.

Polling uses DataUpdate instead of ScreenUpdate to avoid wiping widget state.
Empty mappings passthrough raw source data for internal sources.
2026-06-19 11:26:49 +02:00
b448fa15fe expose h_align/v_align through full stack
display_hint becomes {kind, h_align, v_align} object in API, SQLite
gets alignment columns, SPA widget form gets alignment selects, layout
preview reflects actual alignment instead of hardcoded center
2026-06-19 10:28:09 +02:00
ca2ef61097 update copyright holder name in LICENSE file 2026-06-19 03:30:39 +02:00
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
264 changed files with 19684 additions and 8005 deletions

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
# 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
# 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.

546
Cargo.lock generated
View File

@@ -2,12 +2,71 @@
# 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 = "android_system_properties"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
dependencies = [
"libc",
]
[[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"
@@ -20,9 +79,23 @@ dependencies = [
name = "application" name = "application"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"domain", "domain",
"thiserror", "thiserror",
"tokio", "tokio",
"tracing",
]
[[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]]
@@ -131,6 +204,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 +226,22 @@ dependencies = [
name = "bootstrap" name = "bootstrap"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow",
"application", "application",
"config-memory", "config-sqlite",
"data-generators",
"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 +278,39 @@ 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 = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"wasm-bindgen",
"windows-link",
]
[[package]]
name = "chrono-tz"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf",
]
[[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 +455,29 @@ 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]]
name = "data-generators"
version = "0.1.0"
dependencies = [
"chrono",
"chrono-tz",
"domain",
"thiserror",
]
[[package]] [[package]]
name = "der" name = "der"
version = "0.7.10" version = "0.7.10"
@@ -344,6 +489,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"
@@ -377,6 +528,9 @@ dependencies = [
[[package]] [[package]]
name = "domain" name = "domain"
version = "0.1.0" version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]] [[package]]
name = "dotenvy" name = "dotenvy"
@@ -599,8 +753,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 +770,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 +926,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"
@@ -850,6 +1022,30 @@ dependencies = [
"windows-registry", "windows-registry",
] ]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.2.0" version = "2.2.0"
@@ -963,6 +1159,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 +1191,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 +1288,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 +1313,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 +1345,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 +1383,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 +1418,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 +1460,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 +1538,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"
@@ -1264,6 +1574,24 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@@ -1303,6 +1631,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 +1664,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"
@@ -1346,16 +1692,15 @@ dependencies = [
name = "protocol" name = "protocol"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"domain",
"postcard", "postcard",
"serde", "serde",
] ]
[[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 +1769,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 +1947,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 +2069,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 +2094,24 @@ 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]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -2039,6 +2439,7 @@ dependencies = [
"protocol", "protocol",
"thiserror", "thiserror",
"tokio", "tokio",
"tracing",
] ]
[[package]] [[package]]
@@ -2074,6 +2475,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 +2633,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 +2694,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 +2738,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 +2771,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 +2805,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"
@@ -2436,6 +2937,41 @@ dependencies = [
"wasite", "wasite",
] ]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"

View File

@@ -14,6 +14,9 @@ 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/adapters/data-generators",
"crates/api-types", "crates/api-types",
"crates/bootstrap", "crates/bootstrap",
"crates/client-desktop", "crates/client-desktop",
@@ -34,12 +37,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"] }
@@ -47,3 +58,4 @@ postcard = { version = "1.1", default-features = false, features = ["alloc"] }
tokio = { version = "1.0", features = ["macros", "rt", "rt-multi-thread", "net", "sync", "time", "io-util"] } tokio = { version = "1.0", features = ["macros", "rt", "rt-multi-thread", "net", "sync", "time", "io-util"] }
tower = "0.5" tower = "0.5"
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
data-generators = { path = "crates/adapters/data-generators" }

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gabriel Kaszewski
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.

181
README.md Normal file
View File

@@ -0,0 +1,181 @@
# 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 │
│ data-generators clock, static text │
│ 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, clock, static text
- **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{/}`)
- **Widget alignment**: per-widget horizontal/vertical text alignment (left/center/right, top/middle/bottom), reflected in layout preview
- **Connection indicator**: green/red dot on ESP32 display showing server connectivity
- **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 wiring
2.4" ILI9341 SPI LCD module → ESP-WROOM-32:
| LCD Pin | ESP32 GPIO | Function |
|---------|------------|----------|
| VCC | 3V3 | Power |
| GND | GND | Ground |
| DIN | GPIO23 | SPI MOSI |
| CLK | GPIO18 | SPI SCLK |
| CS | GPIO26 | Chip select |
| DC | GPIO21 | Data/command |
| RST | GPIO22 | Reset |
| BL | 3V3 | Backlight (always on) |
Uses SPI2 (HSPI) at 26 MHz. Pin assignments are in `crates/client-esp32/src/main.rs`.
### 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, UserRepository, WidgetConfig, WidgetId, WidgetState, WidgetStateCache,
};
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,211 @@ 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(())
} }
} }
impl UserRepository for MemoryConfigStore {
type Error = MemoryConfigError;
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)
}
}
impl WidgetStateCache for MemoryConfigStore {
type Error = MemoryConfigError;
async fn save_widget_states(
&self,
_states: &[(WidgetId, WidgetState)],
) -> Result<(), Self::Error> {
Ok(())
}
async fn load_widget_states(&self) -> Result<Vec<(WidgetId, WidgetState)>, Self::Error> {
Ok(vec![])
}
}

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
domain.workspace = true domain = { workspace = true, features = ["json"] }
sqlx.workspace = true sqlx.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true

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,65 @@ 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?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS widget_state_cache (
widget_id INTEGER PRIMARY KEY,
state_json TEXT NOT NULL
)",
)
.execute(&self.pool)
.await?;
// Add alignment columns to widgets (idempotent)
let _ = sqlx::query("ALTER TABLE widgets ADD COLUMN h_align TEXT NOT NULL DEFAULT 'left'")
.execute(&self.pool)
.await;
let _ = sqlx::query("ALTER TABLE widgets ADD COLUMN v_align TEXT NOT NULL DEFAULT 'top'")
.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,17 @@
mod widgets;
mod data_sources; mod data_sources;
mod layout; mod layout;
mod presets; mod presets;
mod theme;
mod users;
mod widget_state_cache;
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, UserRepository, WidgetConfig, WidgetId, WidgetState, WidgetStateCache,
};
impl ConfigRepository for SqliteConfigStore { impl ConfigRepository for SqliteConfigStore {
type Error = SqliteConfigError; type Error = SqliteConfigError;
@@ -70,4 +71,43 @@ 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
}
}
impl UserRepository for SqliteConfigStore {
type Error = SqliteConfigError;
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
}
}
impl WidgetStateCache for SqliteConfigStore {
type Error = SqliteConfigError;
async fn save_widget_states(
&self,
states: &[(WidgetId, WidgetState)],
) -> Result<(), Self::Error> {
self.save_widget_states_impl(states).await
}
async fn load_widget_states(&self) -> Result<Vec<(WidgetId, WidgetState)>, Self::Error> {
self.load_widget_states_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

@@ -0,0 +1,60 @@
use crate::SqliteConfigStore;
use crate::error::SqliteConfigError;
use domain::{Value, WidgetId, WidgetState};
use sqlx::Row;
use std::collections::BTreeMap;
impl SqliteConfigStore {
pub(crate) async fn save_widget_states_impl(
&self,
states: &[(WidgetId, WidgetState)],
) -> Result<(), SqliteConfigError> {
for (id, state) in states {
let json = domain_state_to_json(state)
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
sqlx::query(
"INSERT OR REPLACE INTO widget_state_cache (widget_id, state_json) VALUES (?, ?)",
)
.bind(*id as i64)
.bind(&json)
.execute(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
}
Ok(())
}
pub(crate) async fn load_widget_states_impl(
&self,
) -> Result<Vec<(WidgetId, WidgetState)>, SqliteConfigError> {
let rows = sqlx::query("SELECT widget_id, state_json FROM widget_state_cache")
.fetch_all(&self.pool)
.await
.map_err(SqliteConfigError::Sql)?;
let mut result = Vec::new();
for row in &rows {
let id: i64 = row.get("widget_id");
let json_str: String = row.get("state_json");
let state = json_to_domain_state(&json_str)
.map_err(|e| SqliteConfigError::Serialization(e.to_string()))?;
result.push((id as WidgetId, state));
}
Ok(result)
}
}
fn domain_state_to_json(state: &WidgetState) -> Result<String, serde_json::Error> {
let data: serde_json::Map<String, serde_json::Value> = state
.data
.iter()
.map(|(k, v)| (k.clone(), v.into()))
.collect();
serde_json::to_string(&data)
}
fn json_to_domain_state(json: &str) -> Result<WidgetState, serde_json::Error> {
let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(json)?;
let data: BTreeMap<String, Value> = map.into_iter().map(|(k, v)| (k, v.into())).collect();
Ok(WidgetState { data, error: None })
}

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,20 +26,27 @@ 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_kind_to_str(&config.display_hint);
let h_align_str = ser::h_align_to_str(config.display_hint.h_align);
let v_align_str = ser::v_align_to_str(config.display_hint.v_align);
sqlx::query( sqlx::query(
"INSERT OR REPLACE INTO widgets (id, name, display_hint, data_source_id, mappings, max_data_size) "INSERT OR REPLACE INTO widgets (id, name, display_hint, h_align, v_align, data_source_id, mappings, max_data_size)
VALUES (?, ?, ?, ?, ?, ?)" VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
) )
.bind(config.id as i64) .bind(config.id as i64)
.bind(&config.name) .bind(&config.name)
.bind(hint_str) .bind(hint_str)
.bind(h_align_str)
.bind(v_align_str)
.bind(config.data_source_id as i64) .bind(config.data_source_id as i64)
.bind(&mappings_json) .bind(&mappings_json)
.bind(config.max_data_size as i64) .bind(config.max_data_size as i64)

View File

@@ -1,17 +1,25 @@
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",
DataSourceType::Clock => "clock",
DataSourceType::StaticText => "static_text",
} }
} }
@@ -19,41 +27,140 @@ 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}"))), "clock" => Ok(DataSourceType::Clock),
"static_text" => Ok(DataSourceType::StaticText),
_ => 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(
let v = serde_json::json!({ config: &DataSourceConfig,
"url": config.url, secrets: Option<&(dyn SecretStore + Send + Sync)>,
"headers": config.headers, ) -> Result<String, SqliteConfigError> {
"api_key": config.api_key, let v = match config {
}); DataSourceConfig::External {
url,
headers,
api_key,
} => {
let api_key = api_key.as_ref().map(|k| match secrets {
Some(s) => s.encrypt(k),
None => k.clone(),
});
let headers: Vec<(String, String)> = 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();
serde_json::json!({
"type": "external",
"url": url,
"headers": headers,
"api_key": api_key,
"encrypted": secrets.is_some(),
})
}
DataSourceConfig::Clock { format, timezone } => {
serde_json::json!({
"type": "clock",
"format": format,
"timezone": timezone,
})
}
DataSourceConfig::StaticText { text } => {
serde_json::json!({
"type": "static_text",
"text": text,
})
}
};
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 url = v["url"].as_str().map(String::from); let config_type = v["type"].as_str().unwrap_or("external");
let api_key = v["api_key"].as_str().map(String::from);
let headers = match v["headers"].as_array() {
Some(arr) => arr.iter().filter_map(|h| {
let pair = h.as_array()?;
Some((pair[0].as_str()?.into(), pair[1].as_str()?.into()))
}).collect(),
None => vec![],
};
Ok(DataSourceConfig { url, headers, api_key }) match config_type {
"clock" => {
let format = v["format"].as_str().unwrap_or("%H:%M:%S").to_string();
let timezone = v["timezone"].as_str().unwrap_or("UTC").to_string();
Ok(DataSourceConfig::Clock { format, timezone })
}
"static_text" => {
let text = v["text"].as_str().unwrap_or("").to_string();
Ok(DataSourceConfig::StaticText { text })
}
_ => {
let encrypted = v["encrypted"].as_bool().unwrap_or(false);
let url = v["url"].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() {
Some(arr) => arr
.iter()
.filter_map(|h| {
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![],
};
Ok(DataSourceConfig::External {
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 +172,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,53 +1,105 @@
use crate::error::SqliteConfigError;
use domain::{DisplayHint, DisplayHintKind, HAlign, KeyMapping, VAlign, 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_kind_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> { pub fn h_align_to_str(a: HAlign) -> &'static str {
match a {
HAlign::Left => "left",
HAlign::Center => "center",
HAlign::Right => "right",
}
}
pub fn v_align_to_str(a: VAlign) -> &'static str {
match a {
VAlign::Top => "top",
VAlign::Middle => "middle",
VAlign::Bottom => "bottom",
}
}
fn hint_kind_from_str(s: &str) -> Result<DisplayHintKind, SqliteConfigError> {
match s { match s {
"icon_value" => Ok(DisplayHint::IconValue), "icon_value" => Ok(DisplayHintKind::IconValue),
"text_block" => Ok(DisplayHint::TextBlock), "text_block" => Ok(DisplayHintKind::TextBlock),
"key_value" => Ok(DisplayHint::KeyValue), "key_value" => Ok(DisplayHintKind::KeyValue),
_ => Err(SqliteConfigError::Serialization(format!("unknown display hint: {s}"))), _ => Err(SqliteConfigError::Serialization(format!(
"unknown display hint: {s}"
))),
}
}
fn h_align_from_str(s: &str) -> Result<HAlign, SqliteConfigError> {
match s {
"left" => Ok(HAlign::Left),
"center" => Ok(HAlign::Center),
"right" => Ok(HAlign::Right),
_ => Err(SqliteConfigError::Serialization(format!(
"unknown h_align: {s}"
))),
}
}
fn v_align_from_str(s: &str) -> Result<VAlign, SqliteConfigError> {
match s {
"top" => Ok(VAlign::Top),
"middle" => Ok(VAlign::Middle),
"bottom" => Ok(VAlign::Bottom),
_ => Err(SqliteConfigError::Serialization(format!(
"unknown v_align: {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> {
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 hint_str: String = row.get("display_hint"); let hint_str: String = row.get("display_hint");
let h_align_str: String = row.get("h_align");
let v_align_str: String = row.get("v_align");
let ds_id: i64 = row.get("data_source_id"); let ds_id: i64 = row.get("data_source_id");
let mappings_json: String = row.get("mappings"); let mappings_json: String = row.get("mappings");
let max_size: i64 = row.get("max_data_size"); let max_size: i64 = row.get("max_data_size");
@@ -55,7 +107,11 @@ pub fn widget_from_row(row: &SqliteRow) -> Result<WidgetConfig, SqliteConfigErro
Ok(WidgetConfig { Ok(WidgetConfig {
id: id as u16, id: id as u16,
name, name,
display_hint: display_hint_from_str(&hint_str)?, display_hint: DisplayHint {
kind: hint_kind_from_str(&hint_str)?,
h_align: h_align_from_str(&h_align_str)?,
v_align: v_align_from_str(&v_align_str)?,
},
data_source_id: ds_id as u16, data_source_id: ds_id as u16,
mappings: mappings_from_json(&mappings_json)?, mappings: mappings_from_json(&mappings_json)?,
max_data_size: max_size as u16, max_data_size: max_size as u16,

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, UserRepository, WidgetConfig, WidgetStateCache,
};
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,
} }
@@ -31,7 +36,7 @@ fn weather_source() -> DataSource {
name: "openweather".into(), name: "openweather".into(),
source_type: DataSourceType::Weather, source_type: DataSourceType::Weather,
poll_interval: Duration::from_secs(300), poll_interval: Duration::from_secs(300),
config: DataSourceConfig { config: DataSourceConfig::External {
url: Some("https://api.openweather.org".into()), url: Some("https://api.openweather.org".into()),
headers: vec![], headers: vec![],
api_key: Some("test-key".into()), api_key: Some("test-key".into()),
@@ -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);
@@ -109,8 +125,13 @@ async fn save_and_retrieve_data_source() {
assert_eq!(ds.name, "openweather"); assert_eq!(ds.name, "openweather");
assert_eq!(ds.source_type, DataSourceType::Weather); assert_eq!(ds.source_type, DataSourceType::Weather);
assert_eq!(ds.poll_interval, Duration::from_secs(300)); assert_eq!(ds.poll_interval, Duration::from_secs(300));
assert_eq!(ds.config.url, Some("https://api.openweather.org".into())); match &ds.config {
assert_eq!(ds.config.api_key, Some("test-key".into())); DataSourceConfig::External { url, api_key, .. } => {
assert_eq!(*url, Some("https://api.openweather.org".into()));
assert_eq!(*api_key, Some("test-key".into()));
}
_ => panic!("expected External config"),
}
} }
#[tokio::test] #[tokio::test]
@@ -172,12 +193,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

@@ -0,0 +1,10 @@
[package]
name = "data-generators"
version = "0.1.0"
edition = "2024"
[dependencies]
domain.workspace = true
chrono = "0.4"
chrono-tz = "0.10"
thiserror.workspace = true

View File

@@ -0,0 +1,59 @@
use chrono::Utc;
use chrono_tz::Tz;
use domain::{DataSource, DataSourceConfig, DataSourcePort, Value};
use std::collections::BTreeMap;
#[derive(Default)]
pub struct ClockGenerator;
impl ClockGenerator {
pub fn new() -> Self {
Self
}
}
#[derive(Debug, thiserror::Error)]
pub enum GeneratorError {
#[error("wrong config type for generator")]
WrongConfig,
}
impl DataSourcePort for ClockGenerator {
type Error = GeneratorError;
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
let (fmt, tz_name) = match &source.config {
DataSourceConfig::Clock { format, timezone } => (format.as_str(), timezone.as_str()),
_ => ("%H:%M:%S", "UTC"),
};
let tz: Tz = tz_name.parse().unwrap_or(chrono_tz::UTC);
let now = Utc::now().with_timezone(&tz);
let formatted = now.format(fmt).to_string();
let mut map = BTreeMap::new();
map.insert("time".into(), Value::String(formatted));
Ok(Value::Object(map))
}
}
#[derive(Default)]
pub struct StaticTextGenerator;
impl StaticTextGenerator {
pub fn new() -> Self {
Self
}
}
impl DataSourcePort for StaticTextGenerator {
type Error = GeneratorError;
async fn poll(&self, source: &DataSource) -> Result<Value, Self::Error> {
let text = match &source.config {
DataSourceConfig::StaticText { text } => text.clone(),
_ => String::new(),
};
let mut map = BTreeMap::new();
map.insert("text".into(), Value::String(text));
Ok(Value::Object(map))
}
}

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

@@ -4,13 +4,14 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
domain.workspace = true domain = { workspace = true, features = ["json"] }
application.workspace = true application.workspace = true
api-types.workspace = true api-types.workspace = true
axum.workspace = true 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,101 @@
pub mod extractors;
mod routes; mod routes;
use std::sync::Arc; use application::ConfigService;
use axum::Router; use axum::Router;
use domain::{
AuthPort, BroadcastPort, ClientRegistry, ConfigRepository, EventPublisher, PasswordHashPort,
UserRepository, WidgetStateCache, 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 impl<C, E, W, B, R, A, H> AppState<C, E, W, B, R, A, H>
where where
C: ConfigRepository + Send + Sync + 'static, C: ConfigRepository,
C::Error: std::fmt::Debug + Send, C::Error: std::fmt::Debug,
E: EventPublisher,
E::Error: std::fmt::Debug,
{
pub fn config_service(&self) -> ConfigService<'_, C, E> {
ConfigService::new(self.config.as_ref(), self.events.as_ref())
}
}
pub fn router<C, E, W, B, R, A, H>(state: AppState<C, E, W, B, R, A, H>) -> Router
where
C: ConfigRepository + UserRepository + WidgetStateCache + Send + Sync + 'static,
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
<C as UserRepository>::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 + UserRepository + WidgetStateCache + Send + Sync + 'static,
<C as ConfigRepository>::Error: std::fmt::Debug + Send,
<C as UserRepository>::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, PasswordHashPort, UserRepository};
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: UserRepository,
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: UserRepository,
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: UserRepository,
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 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
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); .into_domain()
svc.create_data_source(source).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; .map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = state.config_service();
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
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); .into_domain()
svc.update_data_source(source).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; .map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = state.config_service();
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 = state.config_service();
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;
type S<C, E> = State<AppState<C, E>>; use axum::{extract::State, http::StatusCode, response::Json};
use domain::{ConfigRepository, EventPublisher};
pub async fn get_layout<C, E>(State(state): S<C, E>) -> Result<Json<Option<LayoutDto>>, StatusCode> type S<C, E, W, B, R, A, H> = State<AppState<C, E, W, B, R, A, H>>;
where C: ConfigRepository, C::Error: std::fmt::Debug, E: EventPublisher, E::Error: std::fmt::Debug,
pub async fn get_layout<C, E, W, B, R, A, H>(
_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
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); .into_domain()
svc.update_layout(layout).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; .map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = state.config_service();
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,102 @@
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,
UserRepository, WidgetStateCache, 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 + UserRepository + WidgetStateCache + Send + Sync + 'static,
C::Error: std::fmt::Debug + Send, <C as ConfigRepository>::Error: std::fmt::Debug + Send,
<C as UserRepository>::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 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
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); .into_domain()
svc.save_preset(preset).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; .map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = state.config_service();
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 = state.config_service();
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 = state.config_service();
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 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 = state.config_service();
svc.update_theme(theme)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?;
Ok(StatusCode::OK)
}

View File

@@ -0,0 +1,43 @@
use crate::AppState;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::Json;
use domain::{ConfigRepository, DomainEvent, EventPublisher};
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,
{
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 data: domain::Value = body.into();
state
.events
.publish(DomainEvent::WebhookDataReceived { source_id, data })
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")))?;
Ok(StatusCode::OK)
}

View File

@@ -1,54 +1,135 @@
use crate::AppState;
use crate::extractors::AuthUser;
use api_types::{CreateWidgetDto, WidgetDto};
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
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); .into_domain()
svc.create_widget(widget).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; .map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = state.config_service();
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
let svc = ConfigService::new(state.config.as_ref(), state.events.as_ref()); .into_domain()
svc.update_widget(widget).await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")))?; .map_err(|e| (StatusCode::BAD_REQUEST, e))?;
let svc = state.config_service();
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 = state.config_service();
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(), v.into())).collect();
Ok(Json(serde_json::Value::Object(map)))
}
None => Err(StatusCode::NOT_FOUND),
}
}

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();
@@ -33,36 +86,54 @@ async fn create_and_get_widget() {
let body = r#"{ let body = r#"{
"id": 1, "id": 1,
"name": "weather", "name": "weather",
"display_hint": "icon_value", "display_hint": {"kind": "icon_value", "h_align": "left", "v_align": "top"},
"data_source_id": 10, "data_source_id": 10,
"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]
async fn list_widgets() { async fn list_widgets() {
let app = test_app(); let app = test_app();
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":{"kind":"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":{"kind":"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,23 @@ 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 = r#"{"id":1,"name":"a","display_hint":{"kind":"icon_value"},"data_source_id":1,"mappings":[]}"#;
app.clone().oneshot(json_request("POST", "/api/widgets", Some(body))).await.unwrap(); 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);
} }
@@ -90,21 +171,21 @@ async fn create_and_get_data_source() {
"name": "weather_api", "name": "weather_api",
"source_type": "weather", "source_type": "weather",
"poll_interval_secs": 300, "poll_interval_secs": 300,
"url": "https://api.openweather.org", "config": {"type": "external", "url": "https://api.openweather.org", "api_key": "test-key", "headers": []}
"api_key": "test-key",
"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 +205,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

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
domain.workspace = true domain = { workspace = true, features = ["json"] }
reqwest.workspace = true reqwest.workspace = true
serde_json.workspace = true serde_json.workspace = true
thiserror.workspace = true thiserror.workspace = true

View File

@@ -14,26 +14,17 @@ 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(),
} }
} }
} }
fn json_to_value(json: serde_json::Value) -> Value { impl HttpJsonAdapter {
match json { pub fn new() -> Self {
serde_json::Value::Null => Value::Null, Self::default()
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::String(s) => Value::String(s),
serde_json::Value::Array(arr) => {
Value::Array(arr.into_iter().map(json_to_value).collect())
}
serde_json::Value::Object(map) => {
Value::Object(map.into_iter().map(|(k, v)| (k, json_to_value(v))).collect())
}
} }
} }
@@ -41,21 +32,29 @@ impl DataSourcePort for HttpJsonAdapter {
type Error = HttpJsonError; type Error = HttpJsonError;
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(HttpJsonError::NoUrl)?; let domain::DataSourceConfig::External {
ref url,
ref headers,
ref api_key,
} = source.config
else {
return Err(HttpJsonError::NoUrl);
};
let url = url.as_ref().ok_or(HttpJsonError::NoUrl)?;
let mut req = self.client.get(url); let mut req = self.client.get(url);
for (key, val) in &source.config.headers { for (key, val) in headers {
req = req.header(key, val); req = req.header(key, val);
} }
if let Some(api_key) = &source.config.api_key { if let Some(api_key) = api_key {
req = req.header("Authorization", format!("Bearer {api_key}")); req = req.header("Authorization", format!("Bearer {api_key}"));
} }
let resp = req.send().await.map_err(HttpJsonError::Request)?; let resp = req.send().await.map_err(HttpJsonError::Request)?;
let json: serde_json::Value = resp.json().await.map_err(HttpJsonError::Request)?; let json: serde_json::Value = resp.json().await.map_err(HttpJsonError::Request)?;
Ok(json_to_value(json)) Ok(json.into())
} }
} }

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();
@@ -30,7 +34,7 @@ fn make_source(url: String) -> DataSource {
name: "test".into(), name: "test".into(),
source_type: DataSourceType::HttpJson, source_type: DataSourceType::HttpJson,
poll_interval: Duration::from_secs(60), poll_interval: Duration::from_secs(60),
config: DataSourceConfig { config: DataSourceConfig::External {
url: Some(url), url: Some(url),
headers: vec![], headers: vec![],
api_key: None, api_key: None,
@@ -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))
@@ -81,7 +82,7 @@ async fn returns_error_when_no_url() {
name: "bad".into(), name: "bad".into(),
source_type: DataSourceType::HttpJson, source_type: DataSourceType::HttpJson,
poll_interval: Duration::from_secs(60), poll_interval: Duration::from_secs(60),
config: DataSourceConfig { config: DataSourceConfig::External {
url: None, url: None,
headers: vec![], headers: vec![],
api_key: None, api_key: None,

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,69 @@ 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 domain::DataSourceConfig::External {
let api_key = source.config.api_key.as_deref().unwrap_or(""); ref url,
ref headers,
..
} = source.config
else {
return Err(MediaError::NoUrl);
};
let base_url = url.as_ref().ok_or(MediaError::NoUrl)?;
let username =
find_header(headers, "username").ok_or(MediaError::MissingField("username"))?;
let password =
find_header(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 +81,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();
@@ -45,10 +45,13 @@ fn make_source(url: String) -> DataSource {
name: "navidrome".into(), name: "navidrome".into(),
source_type: DataSourceType::Media, source_type: DataSourceType::Media,
poll_interval: Duration::from_secs(5), poll_interval: Duration::from_secs(5),
config: DataSourceConfig { config: DataSourceConfig::External {
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,35 @@ 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 domain::DataSourceConfig::External { ref url, .. } = source.config else {
return Err(RssError::NoUrl);
};
let url = 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,8 @@
use tokio::sync::broadcast; use crate::conversions::{display_hint_to_wire, layout_to_wire, widget_state_to_wire};
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, WireTheme, encode};
use tokio::sync::broadcast;
pub struct TcpBroadcaster { pub struct TcpBroadcaster {
tx: broadcast::Sender<Vec<u8>>, tx: broadcast::Sender<Vec<u8>>,
@@ -34,16 +30,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 = layout_to_wire(&layout.root);
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: display_hint_to_wire(hint),
state: state.into(), state: widget_state_to_wire(state),
} })
}).collect(); .collect();
let msg = ServerMessage::ScreenUpdate { let msg = ServerMessage::ScreenUpdate {
layout: wire_layout, layout: wire_layout,
@@ -56,15 +53,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: display_hint_to_wire(hint),
state: state.into(), state: widget_state_to_wire(state),
} })
}).collect(); .collect();
let msg = ServerMessage::DataUpdate { let msg = ServerMessage::DataUpdate {
widgets: wire_widgets, widgets: wire_widgets,
@@ -73,4 +71,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

@@ -0,0 +1,103 @@
use domain::value_objects::{
AlignItems, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent, LayoutNode,
Sizing, VAlign, Value, WidgetError, WidgetState,
};
use protocol::{
WireAlignItems, WireContainerNode, WireDirection, WireDisplayHint, WireDisplayHintKind,
WireHAlign, WireJustifyContent, WireKeyValue, WireLayoutChild, WireLayoutNode, WireSizing,
WireVAlign, WireValue, WireWidgetError, WireWidgetState,
};
pub fn value_to_wire(v: &Value) -> WireValue {
match v {
Value::Null => WireValue::Null,
Value::Bool(b) => WireValue::Bool(*b),
Value::Number(n) => WireValue::Number(*n),
Value::String(s) => WireValue::String(s.clone()),
Value::Array(arr) => WireValue::Array(arr.iter().map(value_to_wire).collect()),
Value::Object(map) => WireValue::Object(
map.iter()
.map(|(k, v)| (k.clone(), value_to_wire(v)))
.collect(),
),
}
}
pub fn widget_error_to_wire(e: &WidgetError) -> WireWidgetError {
match e {
WidgetError::SourceUnavailable => WireWidgetError::SourceUnavailable,
WidgetError::ExtractionFailed => WireWidgetError::ExtractionFailed,
}
}
pub fn widget_state_to_wire(s: &WidgetState) -> WireWidgetState {
WireWidgetState {
data: s
.data
.iter()
.map(|(k, v)| WireKeyValue {
key: k.clone(),
value: value_to_wire(v),
})
.collect(),
error: s.error.as_ref().map(widget_error_to_wire),
}
}
pub fn display_hint_to_wire(h: &DisplayHint) -> WireDisplayHint {
WireDisplayHint {
kind: match h.kind {
DisplayHintKind::IconValue => WireDisplayHintKind::IconValue,
DisplayHintKind::TextBlock => WireDisplayHintKind::TextBlock,
DisplayHintKind::KeyValue => WireDisplayHintKind::KeyValue,
},
h_align: match h.h_align {
HAlign::Left => WireHAlign::Left,
HAlign::Center => WireHAlign::Center,
HAlign::Right => WireHAlign::Right,
},
v_align: match h.v_align {
VAlign::Top => WireVAlign::Top,
VAlign::Middle => WireVAlign::Middle,
VAlign::Bottom => WireVAlign::Bottom,
},
}
}
pub fn layout_to_wire(n: &LayoutNode) -> WireLayoutNode {
match n {
LayoutNode::Leaf(id) => WireLayoutNode::Leaf(*id),
LayoutNode::Container(c) => WireLayoutNode::Container(WireContainerNode {
direction: match c.direction {
Direction::Row => WireDirection::Row,
Direction::Column => WireDirection::Column,
},
gap: c.gap,
padding: c.padding,
justify_content: match c.justify_content {
JustifyContent::Start => WireJustifyContent::Start,
JustifyContent::Center => WireJustifyContent::Center,
JustifyContent::End => WireJustifyContent::End,
JustifyContent::SpaceBetween => WireJustifyContent::SpaceBetween,
JustifyContent::SpaceEvenly => WireJustifyContent::SpaceEvenly,
},
align_items: match c.align_items {
AlignItems::Start => WireAlignItems::Start,
AlignItems::Center => WireAlignItems::Center,
AlignItems::End => WireAlignItems::End,
AlignItems::Stretch => WireAlignItems::Stretch,
},
children: c
.children
.iter()
.map(|ch| WireLayoutChild {
sizing: match ch.sizing {
Sizing::Fixed(px) => WireSizing::Fixed(px),
Sizing::Flex(w) => WireSizing::Flex(w),
},
node: layout_to_wire(&ch.node),
})
.collect(),
}),
}
}

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,12 @@
mod error;
mod broadcaster; mod broadcaster;
mod client_tracker;
mod conversions;
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,128 @@
use crate::broadcaster::domain_theme_to_wire;
use crate::client_tracker::ClientTracker;
use crate::conversions::{display_hint_to_wire, layout_to_wire, widget_state_to_wire};
use crate::error::TcpServerError;
use domain::{ConfigRepository, WidgetStateReader};
use protocol::{ServerMessage, WidgetDescriptor, 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 = layout_to_wire(&layout.root);
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: display_hint_to_wire(&w.display_hint),
state: widget_state_to_wire(&s),
});
}
}
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,40 @@
use serde::{Serialize, Deserialize};
use std::time::Duration;
use domain::*; use domain::*;
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum DataSourceConfigDto {
#[serde(rename = "external")]
External {
#[serde(default)]
url: Option<String>,
#[serde(default)]
api_key: Option<String>,
#[serde(default)]
headers: Vec<(String, String)>,
},
#[serde(rename = "clock")]
Clock {
#[serde(default = "default_clock_format")]
format: String,
#[serde(default = "default_timezone")]
timezone: String,
},
#[serde(rename = "static_text")]
StaticText {
#[serde(default)]
text: String,
},
}
fn default_clock_format() -> String {
"%H:%M:%S".into()
}
fn default_timezone() -> String {
"UTC".into()
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct DataSourceDto { pub struct DataSourceDto {
@@ -8,53 +42,88 @@ pub struct DataSourceDto {
pub name: String, pub name: String,
pub source_type: String, pub source_type: String,
pub poll_interval_secs: u64, pub poll_interval_secs: u64,
pub url: Option<String>, pub config: DataSourceConfigDto,
pub api_key: Option<String>, }
pub headers: Vec<(String, String)>,
fn source_type_to_str(t: &DataSourceType) -> &'static str {
match t {
DataSourceType::Weather => "weather",
DataSourceType::Media => "media",
DataSourceType::Rss => "rss",
DataSourceType::HttpJson => "http_json",
DataSourceType::Webhook => "webhook",
DataSourceType::Clock => "clock",
DataSourceType::StaticText => "static_text",
}
}
fn source_type_from_str(s: &str) -> Result<DataSourceType, String> {
match s {
"weather" => Ok(DataSourceType::Weather),
"media" => Ok(DataSourceType::Media),
"rss" => Ok(DataSourceType::Rss),
"http_json" => Ok(DataSourceType::HttpJson),
"webhook" => Ok(DataSourceType::Webhook),
"clock" => Ok(DataSourceType::Clock),
"static_text" => Ok(DataSourceType::StaticText),
t => Err(format!("unknown source_type: {t}")),
}
} }
impl From<&DataSource> for DataSourceDto { impl From<&DataSource> for DataSourceDto {
fn from(ds: &DataSource) -> Self { fn from(ds: &DataSource) -> Self {
let config = match &ds.config {
DataSourceConfig::External {
url,
api_key,
headers,
} => DataSourceConfigDto::External {
url: url.clone(),
api_key: api_key.clone(),
headers: headers.clone(),
},
DataSourceConfig::Clock { format, timezone } => DataSourceConfigDto::Clock {
format: format.clone(),
timezone: timezone.clone(),
},
DataSourceConfig::StaticText { text } => {
DataSourceConfigDto::StaticText { text: text.clone() }
}
};
Self { Self {
id: ds.id, id: ds.id,
name: ds.name.clone(), name: ds.name.clone(),
source_type: match ds.source_type { source_type: source_type_to_str(&ds.source_type).into(),
DataSourceType::Weather => "weather",
DataSourceType::Media => "media",
DataSourceType::Xtb => "xtb",
DataSourceType::Rss => "rss",
DataSourceType::HttpJson => "http_json",
DataSourceType::Webhook => "webhook",
}.into(),
poll_interval_secs: ds.poll_interval.as_secs(), poll_interval_secs: ds.poll_interval.as_secs(),
url: ds.config.url.clone(), config,
api_key: ds.config.api_key.clone(),
headers: ds.config.headers.clone(),
} }
} }
} }
impl DataSourceDto { impl DataSourceDto {
pub fn into_domain(self) -> Result<DataSource, String> { pub fn into_domain(self) -> Result<DataSource, String> {
let source_type = match self.source_type.as_str() { let source_type = source_type_from_str(&self.source_type)?;
"weather" => DataSourceType::Weather, let config = match self.config {
"media" => DataSourceType::Media, DataSourceConfigDto::External {
"xtb" => DataSourceType::Xtb, url,
"rss" => DataSourceType::Rss, api_key,
"http_json" => DataSourceType::HttpJson, headers,
"webhook" => DataSourceType::Webhook, } => DataSourceConfig::External {
t => return Err(format!("unknown source_type: {t}")), url,
api_key,
headers,
},
DataSourceConfigDto::Clock { format, timezone } => {
DataSourceConfig::Clock { format, timezone }
}
DataSourceConfigDto::StaticText { text } => DataSourceConfig::StaticText { text },
}; };
Ok(DataSource { Ok(DataSource {
id: self.id, id: self.id,
name: self.name, name: self.name,
source_type, source_type,
poll_interval: Duration::from_secs(self.poll_interval_secs), poll_interval: Duration::from_secs(self.poll_interval_secs),
config: DataSourceConfig { config,
url: self.url,
api_key: self.api_key,
headers: self.headers,
},
}) })
} }
} }

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 {
@@ -7,11 +7,28 @@ pub struct KeyMappingDto {
pub target_key: String, pub target_key: String,
} }
#[derive(Serialize, Deserialize)]
pub struct DisplayHintDto {
pub kind: String,
#[serde(default = "default_h_align")]
pub h_align: String,
#[serde(default = "default_v_align")]
pub v_align: String,
}
fn default_h_align() -> String {
"left".into()
}
fn default_v_align() -> String {
"top".into()
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct WidgetDto { pub struct WidgetDto {
pub id: u16, pub id: u16,
pub name: String, pub name: String,
pub display_hint: String, pub display_hint: DisplayHintDto,
pub data_source_id: u16, pub data_source_id: u16,
pub mappings: Vec<KeyMappingDto>, pub mappings: Vec<KeyMappingDto>,
pub max_data_size: u16, pub max_data_size: u16,
@@ -21,30 +38,60 @@ pub struct WidgetDto {
pub struct CreateWidgetDto { pub struct CreateWidgetDto {
pub id: u16, pub id: u16,
pub name: String, pub name: String,
pub display_hint: String, pub display_hint: DisplayHintDto,
pub data_source_id: u16, pub data_source_id: u16,
pub mappings: Vec<KeyMappingDto>, pub mappings: Vec<KeyMappingDto>,
#[serde(default = "default_max_data_size")] #[serde(default = "default_max_data_size")]
pub max_data_size: u16, pub max_data_size: u16,
} }
fn default_max_data_size() -> u16 { 2048 } fn default_max_data_size() -> u16 {
2048
}
fn kind_to_str(kind: &DisplayHintKind) -> &'static str {
match kind {
DisplayHintKind::IconValue => "icon_value",
DisplayHintKind::TextBlock => "text_block",
DisplayHintKind::KeyValue => "key_value",
}
}
fn h_align_to_str(a: HAlign) -> &'static str {
match a {
HAlign::Left => "left",
HAlign::Center => "center",
HAlign::Right => "right",
}
}
fn v_align_to_str(a: VAlign) -> &'static str {
match a {
VAlign::Top => "top",
VAlign::Middle => "middle",
VAlign::Bottom => "bottom",
}
}
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: DisplayHintDto {
DisplayHint::IconValue => "icon_value", kind: kind_to_str(&w.display_hint.kind).into(),
DisplayHint::TextBlock => "text_block", h_align: h_align_to_str(w.display_hint.h_align).into(),
DisplayHint::KeyValue => "key_value", v_align: v_align_to_str(w.display_hint.v_align).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,
} }
} }
@@ -52,21 +99,41 @@ 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 kind = match self.display_hint.kind.as_str() {
"icon_value" => DisplayHint::IconValue, "icon_value" => DisplayHintKind::IconValue,
"text_block" => DisplayHint::TextBlock, "text_block" => DisplayHintKind::TextBlock,
"key_value" => DisplayHint::KeyValue, "key_value" => DisplayHintKind::KeyValue,
h => return Err(format!("unknown display_hint: {h}")), h => return Err(format!("unknown display_hint kind: {h}")),
};
let h_align = match self.display_hint.h_align.as_str() {
"left" => HAlign::Left,
"center" => HAlign::Center,
"right" => HAlign::Right,
h => return Err(format!("unknown h_align: {h}")),
};
let v_align = match self.display_hint.v_align.as_str() {
"top" => VAlign::Top,
"middle" => VAlign::Middle,
"bottom" => VAlign::Bottom,
v => return Err(format!("unknown v_align: {v}")),
}; };
Ok(WidgetConfig { Ok(WidgetConfig {
id: self.id, id: self.id,
name: self.name, name: self.name,
display_hint: hint, display_hint: DisplayHint {
kind,
h_align,
v_align,
},
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,9 @@ edition = "2024"
[dependencies] [dependencies]
domain.workspace = true domain.workspace = true
thiserror.workspace = true thiserror.workspace = true
tokio.workspace = true
anyhow.workspace = true
tracing.workspace = true
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true } tokio = { workspace = true }

View File

@@ -0,0 +1,79 @@
use domain::{AuthPort, PasswordHashPort, User, UserRepository};
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: UserRepository,
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: UserRepository,
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,42 @@
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 async fn seed(&self, states: Vec<(WidgetId, WidgetState)>) {
let mut current = self.current.lock().await;
for (id, state) in states {
current.insert(id, state);
} }
} }
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 +46,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 +59,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

@@ -0,0 +1,123 @@
use crate::DataProjection;
use domain::{
BroadcastPort, ConfigRepository, DomainEvent, Layout, Value, WidgetConfig, WidgetState,
};
use std::sync::Arc;
use tracing::{error, info, warn};
pub async fn handle_event<C, B>(
event: DomainEvent,
config: &Arc<C>,
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) where
C: ConfigRepository,
C::Error: std::fmt::Display,
B: BroadcastPort,
B::Error: std::fmt::Display,
{
match event {
DomainEvent::LayoutChanged { layout } => {
handle_layout_changed(&layout, config, broadcaster, projection).await;
}
DomainEvent::WebhookDataReceived { source_id, data } => {
handle_webhook_data(source_id, &data, config, broadcaster, projection).await;
}
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");
}
_ => {}
}
}
async fn handle_layout_changed<C, B>(
layout: &Layout,
config: &Arc<C>,
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) where
C: ConfigRepository,
C::Error: std::fmt::Display,
B: BroadcastPort,
B::Error: std::fmt::Display,
{
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
error!(error = %e, "failed to fetch widgets for screen update");
return;
}
};
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");
}
async fn handle_webhook_data<C, B>(
source_id: u16,
data: &Value,
config: &Arc<C>,
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) where
C: ConfigRepository,
C::Error: std::fmt::Display,
B: BroadcastPort,
B::Error: std::fmt::Display,
{
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
error!(error = %e, "failed to fetch widgets for webhook");
return;
}
};
let changed = apply_and_broadcast(source_id, data, &widgets, broadcaster, projection).await;
if !changed.is_empty() {
info!(source_id, count = changed.len(), "webhook data pushed");
}
}
pub async fn apply_and_broadcast<B>(
source_id: u16,
data: &Value,
widgets: &[WidgetConfig],
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) -> Vec<(u16, WidgetState)>
where
B: BroadcastPort,
B::Error: std::fmt::Display,
{
let changed: Vec<(u16, WidgetState)> =
projection.apply_poll_result(source_id, data, 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 Err(e) = broadcaster.push_data_update(&with_hints).await {
warn!(error = %e, "failed to push update");
}
}
changed
}

View File

@@ -1,5 +1,8 @@
pub mod auth_service;
mod config_service; mod config_service;
mod data_projection; mod data_projection;
pub mod event_service;
pub mod polling_service;
pub use config_service::ConfigService; pub use config_service::ConfigService;
pub use data_projection::DataProjection; pub use data_projection::DataProjection;

View File

@@ -0,0 +1,254 @@
use crate::DataProjection;
use domain::{
BroadcastPort, ConfigRepository, DataSource, Value, WidgetConfig, WidgetError, WidgetState,
WidgetStateCache,
};
use std::collections::{HashMap, HashSet};
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use tokio::task::JoinHandle;
use tracing::{debug, info, warn};
const SOURCE_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
pub async fn run<C, B, P, F>(
config: Arc<C>,
broadcaster: Arc<B>,
projection: Arc<DataProjection>,
poller: Arc<P>,
) where
C: ConfigRepository + WidgetStateCache + Send + Sync + 'static,
<C as ConfigRepository>::Error: std::fmt::Display + Send,
<C as WidgetStateCache>::Error: std::fmt::Display + Send,
B: BroadcastPort + Send + Sync + 'static,
B::Error: std::fmt::Display + Send,
P: Fn(&DataSource) -> F + Send + Sync + 'static,
F: Future<Output = Result<Value, anyhow::Error>> + Send,
{
let mut running: HashMap<u16, JoinHandle<()>> = HashMap::new();
let mut static_done: HashSet<u16> = HashSet::new();
info!("polling manager started");
loop {
let sources = match config.list_data_sources().await {
Ok(s) => s,
Err(e) => {
warn!(error = %e, "failed to list data sources");
tokio::time::sleep(SOURCE_REFRESH_INTERVAL).await;
continue;
}
};
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
}
});
static_done.retain(|id| current_ids.contains(id));
for source in &sources {
if source.source_type == domain::DataSourceType::Webhook {
continue;
}
if source.source_type == domain::DataSourceType::StaticText {
if static_done.contains(&source.id) {
continue;
}
poll_and_broadcast(&*poller, source, &config, &broadcaster, &projection).await;
static_done.insert(source.id);
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 poller = poller.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, poller).await;
});
running.insert(source_id, handle);
}
if running.is_empty() && static_done.is_empty() {
debug!("no pollable sources, waiting");
}
tokio::time::sleep(SOURCE_REFRESH_INTERVAL).await;
}
}
async fn poll_and_broadcast<C, B, P, F>(
poller: &P,
source: &DataSource,
config: &Arc<C>,
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) where
C: ConfigRepository + WidgetStateCache,
<C as ConfigRepository>::Error: std::fmt::Display,
<C as WidgetStateCache>::Error: std::fmt::Display,
B: BroadcastPort,
B::Error: std::fmt::Display,
P: Fn(&DataSource) -> F,
F: Future<Output = Result<Value, anyhow::Error>>,
{
let result = match poller(source).await {
Ok(v) => v,
Err(e) => {
warn!(source = %source.name, error = %e, "poll failed");
return;
}
};
let widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
warn!(error = %e, "failed to fetch widgets");
return;
}
};
let changed = crate::event_service::apply_and_broadcast(
source.id,
&result,
&widgets,
broadcaster,
projection,
)
.await;
if !changed.is_empty() {
if let Err(e) = config.save_widget_states(&changed).await {
warn!(error = %e, "failed to cache widget states");
}
info!(source = %source.name, count = changed.len(), "pushed widget updates");
}
}
async fn poll_loop<C, B, P, F>(
source: DataSource,
config: Arc<C>,
broadcaster: Arc<B>,
projection: Arc<DataProjection>,
poller: Arc<P>,
) where
C: ConfigRepository + WidgetStateCache,
<C as ConfigRepository>::Error: std::fmt::Display,
<C as WidgetStateCache>::Error: std::fmt::Display,
B: BroadcastPort,
B::Error: std::fmt::Display,
P: Fn(&DataSource) -> F,
F: Future<Output = Result<Value, anyhow::Error>>,
{
let interval = source.poll_interval;
let mut widgets = match config.list_widgets().await {
Ok(w) => w,
Err(e) => {
warn!(error = %e, "failed to fetch initial widget list");
vec![]
}
};
let mut last_refresh = tokio::time::Instant::now();
loop {
let result = match poller(&source).await {
Ok(v) => v,
Err(e) => {
warn!(source = %source.name, error = %e, "poll failed");
broadcast_errors(&source, &widgets, &broadcaster, &projection).await;
tokio::time::sleep(interval).await;
continue;
}
};
if last_refresh.elapsed() >= SOURCE_REFRESH_INTERVAL {
if let Ok(w) = config.list_widgets().await {
widgets = w;
}
last_refresh = tokio::time::Instant::now();
}
let changed = crate::event_service::apply_and_broadcast(
source.id,
&result,
&widgets,
&broadcaster,
&projection,
)
.await;
if !changed.is_empty() {
if let Err(e) = config.save_widget_states(&changed).await {
warn!(error = %e, "failed to cache widget states");
}
info!(source = %source.name, count = changed.len(), "pushed widget updates");
}
tokio::time::sleep(interval).await;
}
}
async fn broadcast_errors<B>(
source: &DataSource,
widgets: &[WidgetConfig],
broadcaster: &Arc<B>,
projection: &Arc<DataProjection>,
) where
B: BroadcastPort,
B::Error: std::fmt::Display,
{
let affected: Vec<_> = widgets
.iter()
.filter(|w| w.data_source_id == source.id)
.collect();
if affected.is_empty() {
return;
}
let mut error_states = Vec::new();
for w in &affected {
let mut state = projection
.get_state(w.id)
.await
.unwrap_or_else(|| WidgetState {
data: std::collections::BTreeMap::new(),
error: None,
});
state.error = Some(WidgetError::SourceUnavailable);
error_states.push((w.id, state));
}
projection.seed(error_states.clone()).await;
let with_hints: Vec<_> = error_states
.iter()
.filter_map(|(id, state)| {
let hint = affected.iter().find(|w| w.id == *id)?.display_hint.clone();
Some((*id, hint, state.clone()))
})
.collect();
if let Err(e) = broadcaster.push_data_update(&with_hints).await {
warn!(error = %e, "failed to push error update");
}
}

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();
@@ -47,7 +47,7 @@ async fn create_data_source_rejects_invalid() {
name: "bad".into(), name: "bad".into(),
source_type: DataSourceType::HttpJson, source_type: DataSourceType::HttpJson,
poll_interval: Duration::from_secs(60), poll_interval: Duration::from_secs(60),
config: DataSourceConfig { config: DataSourceConfig::External {
url: None, url: None,
headers: vec![], headers: vec![],
api_key: None, api_key: None,
@@ -70,7 +70,7 @@ async fn create_data_source_persists_valid_and_emits_event() {
name: "weather".into(), name: "weather".into(),
source_type: DataSourceType::Weather, source_type: DataSourceType::Weather,
poll_interval: Duration::from_secs(300), poll_interval: Duration::from_secs(300),
config: DataSourceConfig { config: DataSourceConfig::External {
url: Some("https://api.weather.com".into()), url: Some("https://api.weather.com".into()),
headers: vec![], headers: vec![],
api_key: None, api_key: None,
@@ -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,16 @@
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, UserRepository, WidgetConfig, WidgetId, WidgetState,
WidgetConfig, WidgetId, DomainEvent, WidgetStateCache,
}; };
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 +20,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 +47,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 +64,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 +95,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 +113,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(())
} }
@@ -100,6 +126,37 @@ impl ConfigRepository for InMemoryConfigRepository {
} }
} }
impl UserRepository for InMemoryConfigRepository {
type Error = Never;
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)
}
}
impl WidgetStateCache for InMemoryConfigRepository {
type Error = Never;
async fn save_widget_states(
&self,
_states: &[(WidgetId, WidgetState)],
) -> Result<(), Self::Error> {
Ok(())
}
async fn load_widget_states(&self) -> Result<Vec<(WidgetId, WidgetState)>, Self::Error> {
Ok(vec![])
}
}
pub struct InMemoryEventPublisher { pub struct InMemoryEventPublisher {
events: Mutex<Vec<DomainEvent>>, events: Mutex<Vec<DomainEvent>>,
} }

View File

@@ -5,8 +5,18 @@ 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
data-generators.workspace = true

View File

@@ -0,0 +1,20 @@
use std::env;
pub struct ServerConfig {
pub database_url: String,
pub tcp_addr: String,
pub http_addr: String,
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()),
spa_dir: env::var("KFRAME_SPA_DIR").ok(),
}
}
}

View File

@@ -0,0 +1,30 @@
use application::DataProjection;
use config_sqlite::SqliteConfigStore;
use std::sync::Arc;
use tcp_server::{TcpBroadcaster, TcpEventBus};
use tracing::{error, 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(event) => {
application::event_service::handle_event(event, &config, &broadcaster, &projection)
.await;
}
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,94 @@
mod config;
mod event_handler;
mod polling;
use anyhow::Result;
use application::DataProjection;
use config_sqlite::SqliteConfigStore;
use domain::WidgetStateCache;
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, warn};
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()); match config_store.load_widget_states().await {
Ok(states) if !states.is_empty() => {
info!(count = states.len(), "loaded cached widget states");
projection.seed(states).await;
}
Ok(_) => {}
Err(e) => warn!(error = %e, "failed to load cached widget states"),
}
service.create_widget(WidgetConfig::new( let tcp_addr = cfg.tcp_addr.clone();
1, "weather".into(), DisplayHint::IconValue, 1, let tcp_bc = broadcaster.clone();
vec![ let tcp_tracker = tracker.clone();
KeyMapping { source_path: "$.temperature".into(), target_key: "value".into() }, let tcp_config = config_store.clone();
KeyMapping { source_path: "$.icon".into(), target_key: "icon".into() }, let tcp_proj = projection.clone();
],
)).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(config_store, broadcaster, projection).await
println!("Sending fake data every 3 seconds...");
let mut projection = DataProjection::new();
let mut counter = 0u32;
loop {
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,77 @@
use anyhow::Result;
use application::DataProjection;
use config_sqlite::SqliteConfigStore;
use data_generators::{ClockGenerator, StaticTextGenerator};
use domain::{DataSource, DataSourcePort, DataSourceType, Value};
use http_json::HttpJsonAdapter;
use media_adapter::MediaAdapter;
use rss_adapter::RssAdapter;
use std::sync::Arc;
use tcp_server::TcpBroadcaster;
#[derive(Clone)]
struct Adapters {
http: Arc<HttpJsonAdapter>,
media: Arc<MediaAdapter>,
rss: Arc<RssAdapter>,
clock: Arc<ClockGenerator>,
static_text: Arc<StaticTextGenerator>,
}
impl Adapters {
async fn poll(&self, source: &DataSource) -> Result<Value> {
match source.source_type {
DataSourceType::HttpJson | DataSourceType::Weather => self
.http
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Media => self
.media
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Rss => self
.rss
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Clock => self
.clock
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::StaticText => self
.static_text
.poll(source)
.await
.map_err(|e| anyhow::anyhow!("{e}")),
DataSourceType::Webhook => Err(anyhow::anyhow!(
"webhook sources are push-based, not polled"
)),
}
}
}
pub async fn run(
config: Arc<SqliteConfigStore>,
broadcaster: Arc<TcpBroadcaster>,
projection: Arc<DataProjection>,
) -> Result<()> {
let adapters = Adapters {
http: Arc::new(HttpJsonAdapter::new()),
media: Arc::new(MediaAdapter::new()),
rss: Arc::new(RssAdapter::new()),
clock: Arc::new(ClockGenerator::new()),
static_text: Arc::new(StaticTextGenerator::new()),
};
let poller = Arc::new(move |source: &DataSource| {
let adapters = adapters.clone();
let source = source.clone();
async move { adapters.poll(&source).await }
});
application::polling_service::run(config, broadcaster, projection, poller).await;
Ok(())
}

View File

@@ -1,22 +1,24 @@
use crate::conversions::{wire_to_display_hint, wire_to_layout, wire_to_widget_state};
use client_domain::{BoundingBox, Color, LayoutEngine, RenderTree, ThemeConfig};
use domain::{DisplayHint, Value, WidgetError, WidgetState};
use protocol::{ServerMessage, WidgetDescriptor, WireColor, WireLayoutNode};
use std::collections::HashMap; use std::collections::HashMap;
use domain::LayoutNode;
use client_domain::{BoundingBox, LayoutEngine, RenderTree};
use protocol::{
ServerMessage, WidgetDescriptor, WireDisplayHint, WireWidgetState, WireLayoutNode,
};
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, (DisplayHint, WidgetState)>,
theme: ThemeConfig,
theme_changed: bool,
} }
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone)]
pub struct RepaintCommand { pub struct RepaintCommand {
pub widget_id: u16, pub widget_id: u16,
pub bounds: BoundingBox, pub bounds: BoundingBox,
pub display_hint: WireDisplayHint, pub display_hint: DisplayHint,
pub state: WireWidgetState, pub data: Vec<(String, Value)>,
pub error: Option<WidgetError>,
} }
impl ClientApp { impl ClientApp {
@@ -25,32 +27,60 @@ 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,
widgets: Vec<WidgetDescriptor>, widgets: Vec<WidgetDescriptor>,
) -> Vec<RepaintCommand> { ) -> Vec<RepaintCommand> {
let layout: LayoutNode = wire_layout.into(); let layout = wire_to_layout(wire_layout);
let new_tree = LayoutEngine::compute(&layout, self.screen); let new_tree = LayoutEngine::compute(&layout, self.screen);
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())); let hint = wire_to_display_hint(w.display_hint);
let state = wire_to_widget_state(w.state);
self.widget_states.insert(w.id, (hint, state));
} }
let repaints = self.build_repaints_for_all(&new_tree); let repaints = self.build_repaints_for_all(&new_tree);
@@ -58,10 +88,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,20 +97,19 @@ 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 hint = wire_to_display_hint(w.display_hint);
let state = wire_to_widget_state(w.state);
let changed = self
.widget_states
.get(&w.id) .get(&w.id)
.map_or(true, |(_, prev_state)| *prev_state != w.state); .is_none_or(|(_, prev)| *prev != 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) {
repaints.push(RepaintCommand { repaints.push(Self::make_repaint(w.id, *bounds, &hint, &state));
widget_id: w.id,
bounds: *bounds,
display_hint: w.display_hint.clone(),
state: w.state.clone(),
});
} }
self.widget_states.insert(w.id, (w.display_hint, w.state)); self.widget_states.insert(w.id, (hint, state));
} }
} }
@@ -95,16 +121,34 @@ impl ClientApp {
for (id, (hint, state)) in &self.widget_states { for (id, (hint, state)) in &self.widget_states {
if let Some(bounds) = tree.get_widget_bounds(*id) { if let Some(bounds) = tree.get_widget_bounds(*id) {
repaints.push(RepaintCommand { repaints.push(Self::make_repaint(*id, *bounds, hint, state));
widget_id: *id,
bounds: *bounds,
display_hint: hint.clone(),
state: state.clone(),
});
} }
} }
repaints.sort_by_key(|r| r.widget_id); repaints.sort_by_key(|r| r.widget_id);
repaints repaints
} }
fn make_repaint(
id: u16,
bounds: BoundingBox,
hint: &DisplayHint,
state: &WidgetState,
) -> RepaintCommand {
RepaintCommand {
widget_id: id,
bounds,
display_hint: hint.clone(),
data: state
.data
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
error: state.error.clone(),
}
}
}
fn wire_color(c: WireColor) -> Color {
Color(c.r, c.g, c.b)
} }

View File

@@ -0,0 +1,42 @@
use client_domain::NetworkPort;
use protocol::{ServerMessage, decode_server_message};
use std::thread;
use std::time::Duration;
pub fn run_connection_loop<N: NetworkPort>(
net: &mut N,
server_addr: &str,
poll_interval: Duration,
reconnect_delay: Duration,
mut on_message: impl FnMut(ServerMessage),
mut on_connection_change: impl FnMut(bool),
) {
loop {
if !net.is_connected() {
match net.connect(server_addr) {
Ok(()) => on_connection_change(true),
Err(_) => {
on_connection_change(false);
thread::sleep(reconnect_delay);
continue;
}
}
}
match net.receive() {
Ok(Some(payload)) => {
if let Ok(msg) = decode_server_message(&payload) {
on_message(msg);
}
}
Ok(None) => {
thread::sleep(poll_interval);
}
Err(_) => {
let _ = net.disconnect();
on_connection_change(false);
thread::sleep(reconnect_delay);
}
}
}
}

View File

@@ -0,0 +1,100 @@
use domain::value_objects::{
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, HAlign, JustifyContent,
LayoutChild, LayoutNode, Sizing, VAlign, Value, WidgetError, WidgetState,
};
use protocol::{
WireAlignItems, WireDirection, WireDisplayHint, WireDisplayHintKind, WireHAlign,
WireJustifyContent, WireLayoutNode, WireSizing, WireVAlign, WireValue, WireWidgetError,
WireWidgetState,
};
pub fn wire_to_value(w: WireValue) -> Value {
match w {
WireValue::Null => Value::Null,
WireValue::Bool(b) => Value::Bool(b),
WireValue::Number(n) => Value::Number(n),
WireValue::String(s) => Value::String(s),
WireValue::Array(arr) => Value::Array(arr.into_iter().map(wire_to_value).collect()),
WireValue::Object(map) => Value::Object(
map.into_iter()
.map(|(k, v)| (k, wire_to_value(v)))
.collect(),
),
}
}
pub fn wire_to_widget_error(w: WireWidgetError) -> WidgetError {
match w {
WireWidgetError::SourceUnavailable => WidgetError::SourceUnavailable,
WireWidgetError::ExtractionFailed => WidgetError::ExtractionFailed,
}
}
pub fn wire_to_widget_state(w: WireWidgetState) -> WidgetState {
WidgetState {
data: w
.data
.into_iter()
.map(|kv| (kv.key, wire_to_value(kv.value)))
.collect(),
error: w.error.map(wire_to_widget_error),
}
}
pub fn wire_to_display_hint(w: WireDisplayHint) -> DisplayHint {
DisplayHint {
kind: match w.kind {
WireDisplayHintKind::IconValue => DisplayHintKind::IconValue,
WireDisplayHintKind::TextBlock => DisplayHintKind::TextBlock,
WireDisplayHintKind::KeyValue => DisplayHintKind::KeyValue,
},
h_align: match w.h_align {
WireHAlign::Left => HAlign::Left,
WireHAlign::Center => HAlign::Center,
WireHAlign::Right => HAlign::Right,
},
v_align: match w.v_align {
WireVAlign::Top => VAlign::Top,
WireVAlign::Middle => VAlign::Middle,
WireVAlign::Bottom => VAlign::Bottom,
},
}
}
pub fn wire_to_layout(w: WireLayoutNode) -> LayoutNode {
match w {
WireLayoutNode::Leaf(id) => LayoutNode::Leaf(id),
WireLayoutNode::Container(c) => LayoutNode::Container(ContainerNode {
direction: match c.direction {
WireDirection::Row => Direction::Row,
WireDirection::Column => Direction::Column,
},
gap: c.gap,
padding: c.padding,
justify_content: match c.justify_content {
WireJustifyContent::Start => JustifyContent::Start,
WireJustifyContent::Center => JustifyContent::Center,
WireJustifyContent::End => JustifyContent::End,
WireJustifyContent::SpaceBetween => JustifyContent::SpaceBetween,
WireJustifyContent::SpaceEvenly => JustifyContent::SpaceEvenly,
},
align_items: match c.align_items {
WireAlignItems::Start => AlignItems::Start,
WireAlignItems::Center => AlignItems::Center,
WireAlignItems::End => AlignItems::End,
WireAlignItems::Stretch => AlignItems::Stretch,
},
children: c
.children
.into_iter()
.map(|ch| LayoutChild {
sizing: match ch.sizing {
WireSizing::Fixed(px) => Sizing::Fixed(px),
WireSizing::Flex(weight) => Sizing::Flex(weight),
},
node: wire_to_layout(ch.node),
})
.collect(),
}),
}
}

View File

@@ -1,3 +1,6 @@
mod client_app; mod client_app;
mod connection_loop;
pub mod conversions;
pub use client_app::{ClientApp, RepaintCommand}; pub use client_app::{ClientApp, RepaintCommand};
pub use connection_loop::run_connection_loop;

View File

@@ -1,9 +1,9 @@
use client_application::{ClientApp, RepaintCommand}; use client_application::ClientApp;
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),
},
], ],
}) })
} }
@@ -75,8 +84,8 @@ fn data_update_only_repaints_changed_widgets() {
assert_eq!(repaints.len(), 1); assert_eq!(repaints.len(), 1);
assert_eq!(repaints[0].widget_id, 1); assert_eq!(repaints[0].widget_id, 1);
assert_eq!( assert_eq!(
repaints[0].state.data[0].value, repaints[0].data[0],
WireValue::String("6.1°C".into()) ("temperature".into(), domain::Value::String("6.1°C".into()))
); );
} }
@@ -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

@@ -0,0 +1,151 @@
use client_application::conversions::{
wire_to_display_hint, wire_to_layout, wire_to_value, wire_to_widget_state,
};
use domain::{
AlignItems, ContainerNode, Direction, DisplayHint, DisplayHintKind, JustifyContent,
LayoutChild, LayoutNode, Sizing, Value, WidgetError, WidgetState,
};
use protocol::{
WireContainerNode, WireDirection, WireDisplayHint, WireKeyValue, WireLayoutChild,
WireLayoutNode, WireSizing, WireValue, WireWidgetError, WireWidgetState,
};
use std::collections::BTreeMap;
fn value_to_wire(v: &Value) -> WireValue {
match v {
Value::Null => WireValue::Null,
Value::Bool(b) => WireValue::Bool(*b),
Value::Number(n) => WireValue::Number(*n),
Value::String(s) => WireValue::String(s.clone()),
Value::Array(arr) => WireValue::Array(arr.iter().map(value_to_wire).collect()),
Value::Object(map) => WireValue::Object(
map.iter()
.map(|(k, v)| (k.clone(), value_to_wire(v)))
.collect(),
),
}
}
#[test]
fn value_converts_to_wire_and_back() {
let original = Value::Object(BTreeMap::from([(
"items".into(),
Value::Array(vec![
Value::String("hello".into()),
Value::Number(42.0),
Value::Bool(true),
Value::Null,
]),
)]));
let wire = value_to_wire(&original);
let roundtripped = wire_to_value(wire);
assert_eq!(original, roundtripped);
}
#[test]
fn widget_state_with_error_converts_to_wire_and_back() {
let original = WidgetState {
data: BTreeMap::from([("temp".into(), Value::Number(5.4))]),
error: Some(WidgetError::SourceUnavailable),
};
let wire = WireWidgetState {
data: original
.data
.iter()
.map(|(k, v)| WireKeyValue {
key: k.clone(),
value: value_to_wire(v),
})
.collect(),
error: Some(WireWidgetError::SourceUnavailable),
};
let roundtripped = wire_to_widget_state(wire);
assert_eq!(original, roundtripped);
}
#[test]
fn layout_tree_converts_to_wire_and_back() {
let original = LayoutNode::Container(ContainerNode {
direction: Direction::Row,
gap: 4,
padding: 2,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![
LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(1),
},
LayoutChild {
sizing: Sizing::Fixed(100),
node: LayoutNode::Container(ContainerNode {
direction: Direction::Column,
gap: 2,
padding: 0,
justify_content: JustifyContent::Start,
align_items: AlignItems::Stretch,
children: vec![LayoutChild {
sizing: Sizing::Flex(1),
node: LayoutNode::Leaf(2),
}],
}),
},
],
});
let wire = WireLayoutNode::Container(WireContainerNode {
direction: WireDirection::Row,
gap: 4,
padding: 2,
justify_content: protocol::WireJustifyContent::Start,
align_items: protocol::WireAlignItems::Stretch,
children: vec![
WireLayoutChild {
sizing: WireSizing::Flex(1),
node: WireLayoutNode::Leaf(1),
},
WireLayoutChild {
sizing: WireSizing::Fixed(100),
node: WireLayoutNode::Container(WireContainerNode {
direction: WireDirection::Column,
gap: 2,
padding: 0,
justify_content: protocol::WireJustifyContent::Start,
align_items: protocol::WireAlignItems::Stretch,
children: vec![WireLayoutChild {
sizing: WireSizing::Flex(1),
node: WireLayoutNode::Leaf(2),
}],
}),
},
],
});
let roundtripped = wire_to_layout(wire);
assert_eq!(original, roundtripped);
}
#[test]
fn display_hint_converts_to_wire_and_back() {
for (hint, wire_kind) in [
(
DisplayHintKind::IconValue,
protocol::WireDisplayHintKind::IconValue,
),
(
DisplayHintKind::TextBlock,
protocol::WireDisplayHintKind::TextBlock,
),
(
DisplayHintKind::KeyValue,
protocol::WireDisplayHintKind::KeyValue,
),
] {
let original = DisplayHint::new(hint);
let wire = WireDisplayHint::new(wire_kind);
let roundtripped = wire_to_display_hint(wire);
assert_eq!(original, roundtripped);
}
}

View File

@@ -1,78 +1,80 @@
use std::thread; use client_application::{ClientApp, RepaintCommand, run_connection_loop};
use std::sync::mpsc; use client_domain::{
use std::time::Duration; BoundingBox, DisplayPort, FontMetrics, RenderEngine, RepaintRequest, ThemeConfig,
use client_domain::{BoundingBox, DisplayPort, NetworkPort}; WidgetRenderer,
use client_application::ClientApp; };
use tcp_client::StdTcpClient;
use display_terminal::TerminalDisplay; use display_terminal::TerminalDisplay;
use protocol::decode_server_message; use protocol::ServerMessage;
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use tcp_client::StdTcpClient;
fn to_request(cmd: &RepaintCommand) -> RepaintRequest {
RepaintRequest {
widget_id: cmd.widget_id,
bounds: cmd.bounds,
display_hint: cmd.display_hint.clone(),
data: cmd.data.clone(),
error: cmd.error.clone(),
}
}
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());
let mut renderer = WidgetRenderer::new();
println!("=== K-Frame Desktop Client ==="); println!("=== K-Frame Desktop Client ===");
println!("Screen: {}x{}", screen.width, screen.height); println!("Screen: {}x{}", screen.width, screen.height);
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel::<ServerMessage>();
thread::spawn(move || { thread::spawn(move || {
let server_addr = "127.0.0.1:2699";
let mut net = StdTcpClient::new(); let mut net = StdTcpClient::new();
let tx_clone = tx.clone();
loop { run_connection_loop(
if !net.is_connected() { &mut net,
println!("[NET] Connecting to {server_addr}..."); "127.0.0.1:2699",
match net.connect(server_addr) { Duration::from_millis(50),
Ok(()) => println!("[NET] Connected!"), Duration::from_secs(2),
Err(e) => { move |msg| {
println!("[NET] Connection failed: {e}, retrying in 2s..."); let _ = tx_clone.send(msg);
thread::sleep(Duration::from_secs(2)); },
continue; |_connected| {},
} );
}
}
match net.receive() {
Ok(Some(payload)) => {
match decode_server_message(&payload) {
Ok(msg) => { let _ = tx.send(msg); }
Err(e) => println!("[NET] Decode error: {e}"),
}
}
Ok(None) => {
thread::sleep(Duration::from_millis(50));
}
Err(e) => {
println!("[NET] Receive error: {e}, reconnecting...");
let _ = net.disconnect();
thread::sleep(Duration::from_secs(2));
}
}
}
}); });
println!("[RENDER] Render loop started"); println!("[RENDER] Render loop started");
let mut last_tick = Instant::now();
loop { loop {
match rx.recv_timeout(Duration::from_millis(100)) { match rx.recv_timeout(Duration::from_millis(50)) {
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());
for cmd in &repaints {
display.clear_region(cmd.bounds).unwrap();
display.fill_background(cmd.bounds).unwrap();
for kv in &cmd.state.data { let requests: Vec<_> = repaints.iter().map(to_request).collect();
if let protocol::WireValue::String(s) = &kv.value { let bg = engine.theme().background;
display.draw_text( let updates = renderer.apply_repaints(&engine, requests);
&format!("{}: {s}", kv.key), for update in &updates {
cmd.bounds.x, cmd.bounds.y, display.fill_rect(update.bounds, bg).unwrap();
cmd.bounds, for dc in &update.commands {
).unwrap(); display
} .draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
.unwrap();
} }
} }
display.flush().unwrap(); display.flush().unwrap();
@@ -81,5 +83,23 @@ fn main() {
Err(mpsc::RecvTimeoutError::Timeout) => {} Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => break, Err(mpsc::RecvTimeoutError::Disconnected) => break,
} }
let now = Instant::now();
let elapsed = now.duration_since(last_tick);
last_tick = now;
let scroll_updates = renderer.tick_scroll(&engine, elapsed);
if !scroll_updates.is_empty() {
let bg = engine.theme().background;
for update in &scroll_updates {
display.fill_rect(update.bounds, bg).unwrap();
for dc in &update.commands {
display
.draw_text_span(&dc.text, dc.x, dc.y, dc.color, dc.font)
.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,28 @@
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;
mod widget_renderer;
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::{DisplayPort, NetworkPort};
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;
pub use widget_renderer::{RenderUpdate, RepaintRequest, WidgetRenderer};

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