Compare commits
25 Commits
af47e3939c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fa097771d4 | |||
| 7001b5e911 | |||
| 1c854d127f | |||
| 838e29702a | |||
| 5bcf4c4e0d | |||
| 27c1fe3f37 | |||
| b964801765 | |||
| 13497dd53c | |||
| 8b1dac9669 | |||
| a6152c9a9a | |||
| 455d5da901 | |||
| 437056cfc4 | |||
| a51d22649a | |||
| b448fa15fe | |||
| ca2ef61097 | |||
| e8b968bcd1 | |||
| fe59b68c37 | |||
| 81a4167382 | |||
| 0a90d6a5d7 | |||
| adda731dc6 | |||
| 4139330234 | |||
| 1d7b5324d6 | |||
| 26ebfad3a2 | |||
| 21c08911df | |||
| 15b75d860c |
17
.env.example
Normal file
17
.env.example
Normal 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
4
.gitignore
vendored
@@ -1 +1,5 @@
|
|||||||
target/
|
target/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
.env
|
||||||
|
|||||||
22
CONTEXT.md
22
CONTEXT.md
@@ -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
546
Cargo.lock
generated
@@ -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"
|
||||||
|
|||||||
14
Cargo.toml
14
Cargo.toml
@@ -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
21
LICENSE
Normal 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.
|
||||||
5
Makefile
5
Makefile
@@ -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
181
README.md
Normal 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)
|
||||||
11
crates/adapters/auth/Cargo.toml
Normal file
11
crates/adapters/auth/Cargo.toml
Normal 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"] }
|
||||||
90
crates/adapters/auth/src/lib.rs
Normal file
90
crates/adapters/auth/src/lib.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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![])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
37
crates/adapters/config-sqlite/src/repository/theme.rs
Normal file
37
crates/adapters/config-sqlite/src/repository/theme.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
48
crates/adapters/config-sqlite/src/repository/users.rs
Normal file
48
crates/adapters/config-sqlite/src/repository/users.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"))),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
40
crates/adapters/config-sqlite/src/serialization/theme.rs
Normal file
40
crates/adapters/config-sqlite/src/serialization/theme.rs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
10
crates/adapters/data-generators/Cargo.toml
Normal file
10
crates/adapters/data-generators/Cargo.toml
Normal 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
|
||||||
59
crates/adapters/data-generators/src/lib.rs
Normal file
59
crates/adapters/data-generators/src/lib.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
42
crates/adapters/http-api/src/extractors.rs
Normal file
42
crates/adapters/http-api/src/extractors.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
85
crates/adapters/http-api/src/routes/auth.rs
Normal file
85
crates/adapters/http-api/src/routes/auth.rs
Normal 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
29
crates/adapters/http-api/src/routes/clients.rs
Normal file
29
crates/adapters/http-api/src/routes/clients.rs
Normal 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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
46
crates/adapters/http-api/src/routes/theme.rs
Normal file
46
crates/adapters/http-api/src/routes/theme.rs
Normal 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)
|
||||||
|
}
|
||||||
43
crates/adapters/http-api/src/routes/webhook.rs
Normal file
43
crates/adapters/http-api/src/routes/webhook.rs
Normal 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)
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
11
crates/adapters/secret-store/Cargo.toml
Normal file
11
crates/adapters/secret-store/Cargo.toml
Normal 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"] }
|
||||||
56
crates/adapters/secret-store/src/lib.rs
Normal file
56
crates/adapters/secret-store/src/lib.rs
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
crates/adapters/tcp-server/src/client_tracker.rs
Normal file
48
crates/adapters/tcp-server/src/client_tracker.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
103
crates/adapters/tcp-server/src/conversions.rs
Normal file
103
crates/adapters/tcp-server/src/conversions.rs
Normal 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(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
17
crates/api-types/src/client.rs
Normal file
17
crates/api-types/src/client.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()?,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
52
crates/api-types/src/theme.rs
Normal file
52
crates/api-types/src/theme.rs
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
79
crates/application/src/auth_service.rs
Normal file
79
crates/application/src/auth_service.rs
Normal 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(())
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
123
crates/application/src/event_service.rs
Normal file
123
crates/application/src/event_service.rs
Normal 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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
254
crates/application/src/polling_service.rs
Normal file
254
crates/application/src/polling_service.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { .. }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
20
crates/bootstrap/src/config.rs
Normal file
20
crates/bootstrap/src/config.rs
Normal 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
crates/bootstrap/src/event_handler.rs
Normal file
30
crates/bootstrap/src/event_handler.rs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
77
crates/bootstrap/src/polling.rs
Normal file
77
crates/bootstrap/src/polling.rs
Normal 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(())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
42
crates/client-application/src/connection_loop.rs
Normal file
42
crates/client-application/src/connection_loop.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
100
crates/client-application/src/conversions.rs
Normal file
100
crates/client-application/src/conversions.rs
Normal 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(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
151
crates/client-application/tests/conversion_tests.rs
Normal file
151
crates/client-application/tests/conversion_tests.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
crates/client-domain/src/alignment.rs
Normal file
41
crates/client-domain/src/alignment.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
crates/client-domain/src/color.rs
Normal file
2
crates/client-domain/src/color.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Color(pub u8, pub u8, pub u8);
|
||||||
31
crates/client-domain/src/font.rs
Normal file
31
crates/client-domain/src/font.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user