diff --git a/.claude/skills/halo-app-design/SKILL.md b/.claude/skills/halo-app-design/SKILL.md
index 9c017a8f..e664c96c 100644
--- a/.claude/skills/halo-app-design/SKILL.md
+++ b/.claude/skills/halo-app-design/SKILL.md
@@ -52,4 +52,4 @@ talking. Optional warm/cool weather hues alongside the accent when data demands.
| Device | wall-mounted touch panel — no hover, big targets/numbers |
| Density | data-dense (clock, energy charts, Hue cards) |
| Motion | drawer unfold (150ms), colon pulse each second, breathing lit bulbs, energy-flow stroke scroll, eased counters |
-| Icons | `lucide-react`, inline per-icon import, `currentColor` (migrated off Material Icons) |
+| Icons | `lucide-react`, inline per-icon import, `currentColor` — **migration in progress**: 5 files done, ~12 still render Material Icons through `components/Icon.tsx` (and `index.html` still loads the two Material Icons stylesheets). New code uses lucide; convert the Material call sites you touch. |
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..453184ff
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,29 @@
+# EditorConfig — https://editorconfig.org
+# The cross-language source of truth for indentation, line width, and newline
+# hygiene. Prettier reads it (indent_style, indent_size, max_line_length,
+# end_of_line); editors read it natively; rustfmt ignores it but already matches.
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+indent_style = space
+indent_size = 2
+# Prettier's default is 80. Line width is the one knob most repos override; set
+# it here once and every editor + Prettier follows.
+max_line_length = 100
+
+# Rust (rustfmt) indents with 4 spaces.
+[*.rs]
+indent_size = 4
+
+[Makefile]
+indent_style = tab
+
+# Markdown keeps trailing whitespace (two trailing spaces = a hard line break).
+[*.md]
+trim_trailing_whitespace = false
+
+# YAML inherits spaces from [*] above — never set it to tabs (illegal in YAML).
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index 2840bbc4..8238b8b1 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -12,6 +12,6 @@ fi
if echo "$STAGED" | grep -q "^backend/"; then
echo "Running backend lint..."
- (cd "$ROOT/backend" && cargo fmt --check)
- (cd "$ROOT/backend" && cargo clippy -- -D warnings)
+ (cd "$ROOT/backend" && cargo fmt --all -- --check)
+ (cd "$ROOT/backend" && cargo clippy --workspace --all-targets -- -D warnings)
fi
diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml
index 1addf2b0..9ab2fb4d 100644
--- a/.github/dependabot.yaml
+++ b/.github/dependabot.yaml
@@ -38,8 +38,15 @@ updates:
- "minor"
- "patch"
- # github-actions only honours `default-days` — the semver-* keys silently
- # break parsing for this ecosystem, so they're omitted.
+ # docker + github-actions only honour `default-days` — the semver-* keys
+ # silently break parsing for these ecosystems, so they're omitted.
+ - package-ecosystem: "docker"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ cooldown:
+ default-days: 7
+
- package-ecosystem: "github-actions"
directory: "/"
schedule:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index a4528647..469292d1 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -17,14 +17,21 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
- node-version: "24"
- - run: corepack enable
- - run: yarn install --immutable
- - run: yarn playwright install chromium
- - run: yarn format
- - run: yarn lint
- - run: yarn test
- - run: yarn build
+ # The pinned major lives in one place; a hardcoded literal here would
+ # silently drift from the file and the Dockerfile's node stage.
+ node-version-file: frontend/.node-version
+ # Yarn is vendored (.yarn/releases/*.cjs) and invoked via node — no
+ # corepack (node 25+ dropped the bundle).
+ - name: Frontend checks
+ run: |
+ yarn() { node .yarn/releases/yarn-*.cjs "$@"; }
+ yarn install --immutable
+ yarn playwright install chromium
+ yarn format
+ yarn lint
+ yarn typecheck
+ yarn test
+ yarn build
backend:
name: "Backend"
@@ -40,7 +47,7 @@ jobs:
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: backend
- - run: cargo fmt --check
- - run: cargo clippy -- -D warnings
- - run: cargo test
- - run: cargo build
+ - run: cargo fmt --all -- --check
+ - run: cargo clippy --workspace --all-targets -- -D warnings
+ - run: cargo test --workspace
+ - run: cargo build --workspace --release
diff --git a/.github/workflows/cve-scan.yaml b/.github/workflows/cve-scan.yaml
new file mode 100644
index 00000000..62d8af27
--- /dev/null
+++ b/.github/workflows/cve-scan.yaml
@@ -0,0 +1,47 @@
+# Weekly trivy scan of the published image → SARIF to the Security tab.
+# Report-only: never fails the run.
+name: CVE scan (GHCR image)
+
+on:
+ schedule:
+ - cron: "0 6 * * 1" # Mondays 06:00 UTC
+ workflow_dispatch:
+
+env:
+ REGISTRY: ghcr.io
+
+jobs:
+ scan:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: read
+ security-events: write
+
+ steps:
+ - name: Log in to the Container registry
+ uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Trivy scan
+ uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
+ with:
+ image-ref: ${{ env.REGISTRY }}/${{ github.repository }}:main
+ format: sarif
+ output: trivy.sarif
+ severity: CRITICAL,HIGH
+ exit-code: "0"
+ # Drop CVEs with no upstream fix — the scratch Rust image carries
+ # almost no userland; self-heals once a fix ships (the CVE resurfaces).
+ ignore-unfixed: true
+ env:
+ TRIVY_DB_REPOSITORY: ghcr.io/aquasecurity/trivy-db,public.ecr.aws/aquasecurity/trivy-db
+
+ - name: Upload SARIF to Security tab
+ uses: github/codeql-action/upload-sarif@c54b30b7df092240050e69945842bc67aee0f0f4 # v4.37.3
+ if: always()
+ with:
+ sarif_file: trivy.sarif
diff --git a/CLAUDE.md b/CLAUDE.md
index af62835a..2f6e0fad 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,7 +9,9 @@ siblings [chat](../chat), [scribe](../scribe), [ocular](../ocular),
```
backend/ actix-web 4 — Hue/FMI/SolisCloud clients, SQLite (WAL), polling loops
-frontend/ Vite + React 19 + Emotion + TanStack Router (file-based)
+frontend/ Vite + React 19 + Emotion; views are switched by App.tsx state,
+ not a router
+justfile task runner — `just dev` runs backend (bacon) + frontend together
scripts/ refresh-pv-forecast.sh (cron-driven PV upsert) + misc
documentation/ design notes
.claude/skills/ halo-app-design skill (visual language, brand)
@@ -32,11 +34,25 @@ Per-area instructions in `backend/CLAUDE.md` and `frontend/CLAUDE.md`.
disabled).
- **Hue events.** `GET /api/hue/events` is an SSE stream off a broadcast channel
— live bridge-state pushes, not polling.
+- **Installable + touch-first.** The SPA ships a web manifest and a full icon set
+ (`frontend/scripts/gen-icons.sh` regenerates the PNGs from `favicon.svg` —
+ rerun and commit after editing it). The layout assumes `viewport-fit=cover`:
+ `#root` is the scroll container and pads off `env(safe-area-inset-*)`, and
+ height is `100vh` when installed (`html.standalone`, set in `main.tsx`) vs
+ `100dvh` in a tab — iOS resolves `100dvh` short at cold start in a PWA.
+- **Stale-tab guard.** Frontend and backend ship in one image, so a deploy
+ replaces both. `vite.config.ts` emits `version.json` holding the baked
+ `VITE_HALO_IMAGE_TAG`; `useDeployReload` polls it every 60 s and reloads when
+ it differs. Keyed off the image tag, not a semver — a `:main` rebuild never
+ bumps a semver.
## Working on this repo
-- Backend `:3000` (`PORT`); needs a `.env` with at least `HUE_BRIDGE_ADDRESS` /
- `HUE_BRIDGE_USER` (pair via `POST /api/hue/pair`). Run: `cargo run -p halo-backend`.
+- `just dev` runs both halves together (backend headless under bacon + Vite) and
+ tears them down on one Ctrl-C. `just --list` for the rest.
+- Backend `:3000` (`PORT`); needs `backend/.env` with at least
+ `HUE_BRIDGE_ADDRESS` / `HUE_BRIDGE_USER` (pair via `POST /api/hue/pair`).
+ Alone: `cd backend && bacon` (or `cargo run`).
- Frontend dev `:5173` (`yarn dev`); Vite proxies `/api`, `/hue/events`,
`/status` to `:3000`.
- Key env: `SOLIS_KEY_ID/SECRET/STATION_ID`, `TOMORROW_IO_API_KEY` (fallback
@@ -47,7 +63,9 @@ Per-area instructions in `backend/CLAUDE.md` and `frontend/CLAUDE.md`.
- Authentication / per-user data
- Shared/team dashboards, admin UI
-- Mobile/native app (web only, touch-friendly)
+- A native app — but the web UI is touch-first and installable (see
+ "Installable + touch-first"); phones are a supported target, not an
+ afterthought
- Non-Hue smart-home integrations
If a feature crosses into those areas, raise it before implementing.
diff --git a/Dockerfile b/Dockerfile
index 46c7f17c..a255397e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -11,9 +11,11 @@ WORKDIR /app
COPY frontend/package.json frontend/yarn.lock frontend/.yarnrc.yml* ./
# .yarnrc.yml pins yarnPath to a committed release; copy it in before install
COPY frontend/.yarn/ ./.yarn/
-RUN corepack enable && yarn install --immutable --network-timeout 1000000
+# Invoke the vendored yarn through node — no corepack (node 25+ dropped the
+# bundle), so this stage stays independent of the node version.
+RUN node .yarn/releases/yarn-*.cjs install --immutable --network-timeout 1000000
COPY frontend/ .
-RUN yarn build
+RUN node .yarn/releases/yarn-*.cjs build
# --- Stage 2: Build backend dependencies (native, cross-compiled) ---
FROM --platform=$BUILDPLATFORM rust:1-alpine AS backend-deps
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..a35c6b48
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,76 @@
+# Security
+
+halo is a self-hosted, single-household wall dashboard. It runs on a home Pi and
+is reachable only from the LAN — it is **not** a public or multi-tenant service.
+The threat model is: everything on the LAN is trusted, so the controls that
+matter are keeping the upstream credentials (Hue bridge user, SolisCloud API
+key/secret, weather API key) out of the image and out of the browser, and never
+exposing the app beyond the LAN.
+
+## Trust boundaries
+
+- **Network is the boundary — there is no app auth.** Every route is public by
+ design: no login, no session, no forward-auth, no `DEV_AUTH`. Anyone who can
+ reach the port can read every reading and toggle every light. This is
+ deliberate for a wall panel, and the only thing keeping it safe is reachability:
+ halo sits behind Traefik on a subdomain whose DNS record — public though it is
+ (`public_dns: True` in `../raspi`) — resolves to the **LAN IP**, with no port
+ forward. Its route is deliberately **not** in Traefik's `_gated_hosts`, so
+ there is no oauth2-proxy in front of it either. Making halo reachable from the
+ internet therefore requires adding auth first; nothing in the app would stop
+ it otherwise.
+
+- **No per-user isolation, by design.** There is no user model. `user_settings`
+ is a single global row (`id=1`) shared by every viewer; anything written there
+ is visible to everyone on the LAN.
+
+- **CORS is `Cors::permissive()`.** Acceptable only because the app is LAN-only
+ and holds nothing a cross-origin read could escalate — there are no cookies,
+ no session, and no auth header to steal, so a hostile page can at most do what
+ any LAN client can already do directly. Tighten this to an origin allowlist if
+ halo ever gains auth or leaves the LAN.
+
+- **No CSP / security response headers are set.** Same rationale; the SPA is
+ served from `STATIC_DIR` with no user-supplied HTML anywhere in the response
+ path. Worth adding if the trust model changes.
+
+- **Outbound fetches.** The backend calls fixed, config-pinned upstreams over
+ verified TLS: the Hue bridge (LAN), FMI (WFS/WMS/download), tomorrow.io,
+ SolisCloud, and the spot-price API. Base URLs come from env, not from request
+ input, so no request can steer an outbound fetch at an arbitrary host. halo is
+ deliberately **not** in `../raspi`'s `network_restrict.RESTRICTED` — unlike the
+ LAN-only siblings it needs egress for those upstreams.
+
+- **`POST /api/pv/forecast` is an unauthenticated write.** The cron-driven
+ `scripts/refresh-pv-forecast.sh` upserts ~66 rows through it. Any LAN client
+ can also write those rows; the blast radius is a wrong PV forecast until the
+ next 3-hourly refresh overwrites it.
+
+## Secrets
+
+All secrets are injected at runtime via env, never baked into the image or
+committed: `HUE_BRIDGE_USER`, `SOLIS_KEY_ID`, `SOLIS_KEY_SECRET`,
+`SOLIS_STATION_ID`, `TOMORROW_IO_API_KEY`. On the Pi they come from
+`/etc/secrets/halo.env` (written by `../raspi`'s `tasks/secrets.py`). `.env`,
+`.env.pv`, and `*.db*` are gitignored. The container runs as UID 1000 on
+`scratch`.
+
+## Accepted risks
+
+- **Unauthenticated light control and history writes from the LAN.** Accepted:
+ the panel exists to be tapped without a login, and the LAN is the trust
+ boundary. Revisit if halo is ever exposed beyond the LAN, or if guest/IoT
+ devices share the same segment.
+- **Permissive CORS + no CSP.** Accepted for the same reason (see above).
+ Revisit alongside any auth work.
+
+## Out of scope
+
+Authentication, per-user data, shared/team dashboards, and rate-limiting against
+a hostile LAN client are deliberately not built — see the root `CLAUDE.md`
+"Out of scope". Don't add them piecemeal; they change the trust model above.
+
+## Reporting
+
+This is a personal project. Flag an issue privately to the maintainer rather
+than opening a public issue with exploit detail.
diff --git a/backend/bacon.toml b/backend/bacon.toml
index f4bac8a1..aeb0c951 100644
--- a/backend/bacon.toml
+++ b/backend/bacon.toml
@@ -1,9 +1,19 @@
+# Run `bacon` (TUI) for local work, or `bacon --headless -j run` (what the root
+# `just dev` recipe invokes) to stream logs into the composed dev output. Both
+# auto-reload on source + .env changes; the binary loads backend/.env via
+# dotenvy, and watching .env restarts when that changes too.
default_job = "run"
+[jobs.check]
+command = ["cargo", "check", "--color", "always"]
+need_stdout = false
+
[jobs.run]
-command = ["cargo", "run"]
+command = ["cargo", "run", "--color", "always"]
watch = ["src", ".env"]
default_watch = true
need_stdout = true
+allow_warnings = true
+background = true
on_change_strategy = "kill_then_restart"
apply_gitignore = false
diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
index af8c0f57..cace053c 100644
--- a/frontend/CLAUDE.md
+++ b/frontend/CLAUDE.md
@@ -10,4 +10,15 @@ Individual scripts:
- `yarn format` / `yarn format:fix`
- `yarn typecheck`
-Use yarn (not npm).
+Use yarn (not npm). It's vendored at `.yarn/releases/` and pinned by `yarnPath`
+— CI and Docker call it as `node .yarn/releases/yarn-*.cjs`, no corepack.
+
+## Icons
+
+- **UI icons:** `lucide-react`, imported per-icon. `components/Icon.tsx` is the
+ legacy Material Icons wrapper — don't add call sites, convert the ones you
+ touch.
+- **App icons:** `scripts/gen-icons.sh` regenerates `public/icon-*.png` +
+ `apple-touch-icon.png` from `public/favicon.svg` (needs librsvg + ImageMagick).
+ Rerun and commit the PNGs after editing the SVG — the build ships no
+ rasterizer.
diff --git a/frontend/index.html b/frontend/index.html
index 21f536de..d476b2a3 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -3,13 +3,24 @@
-
+
+
+
+
+
+
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
index 323ae4e8..f4e8eb69 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,5 +1,5 @@
{
- "name": "vite_react_template",
+ "name": "halo-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png
index 12c66638..c8dce888 100644
Binary files a/frontend/public/apple-touch-icon.png and b/frontend/public/apple-touch-icon.png differ
diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png
new file mode 100644
index 00000000..8c4b1ae0
Binary files /dev/null and b/frontend/public/icon-192.png differ
diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png
new file mode 100644
index 00000000..e8b50af3
Binary files /dev/null and b/frontend/public/icon-512.png differ
diff --git a/frontend/public/icon-maskable-512.png b/frontend/public/icon-maskable-512.png
new file mode 100644
index 00000000..ceb2601b
Binary files /dev/null and b/frontend/public/icon-maskable-512.png differ
diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest
new file mode 100644
index 00000000..cce5921e
--- /dev/null
+++ b/frontend/public/manifest.webmanifest
@@ -0,0 +1,20 @@
+{
+ "name": "halo",
+ "short_name": "halo",
+ "description": "koti — valot, lämpötilat, sää ja aurinkoenergia",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "background_color": "#f0f0f0",
+ "theme_color": "#f0f0f0",
+ "icons": [
+ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
+ { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
+ {
+ "src": "/icon-maskable-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/frontend/scripts/gen-icons.sh b/frontend/scripts/gen-icons.sh
new file mode 100755
index 00000000..8040f1c9
--- /dev/null
+++ b/frontend/scripts/gen-icons.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+# Regenerate the PWA / home-screen icon PNGs from public/favicon.svg.
+#
+# Icons are FULL-BLEED SQUARES — iOS and Android round home-screen / PWA icons
+# themselves, so we must NOT bake rounded corners into the source. favicon.svg is
+# a plain square (no rx); the OS applies its own mask. The maskable icon is
+# DERIVED here (glyph shrunk into the ~80% safe zone) — no separate source SVG.
+#
+# Requires librsvg (`brew install librsvg`) + ImageMagick. Rerun after editing
+# favicon.svg and commit the PNGs (the build ships no rasterizer).
+set -euo pipefail
+cd "$(dirname "$0")/../public"
+# halo's ring strokes #525252 — it reads on the light body, not on the dark one,
+# so the icon bg is --halo-body light. -b fills the corners opaque so none round.
+BG="#f0f0f0"
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+
+# any-purpose icons + browser favicon fallback (full-bleed square)
+rsvg-convert -w 192 -h 192 -b "$BG" favicon.svg -o icon-192.png
+rsvg-convert -w 512 -h 512 -b "$BG" favicon.svg -o icon-512.png
+
+# apple-touch-icon: opaque, no alpha (Apple guidance), square
+rsvg-convert -w 180 -h 180 -b "$BG" favicon.svg -o "$tmp/ati.png"
+magick "$tmp/ati.png" -background "$BG" -flatten -alpha off -type TrueColor PNG24:apple-touch-icon.png
+
+# maskable: glyph shrunk into the ~80% safe zone on a full-bleed bg so Android's
+# adaptive mask can't clip it
+rsvg-convert -w 410 -h 410 -b "$BG" favicon.svg -o "$tmp/glyph.png"
+magick -size 512x512 "xc:$BG" "$tmp/glyph.png" -gravity center -composite \
+ -alpha off -type TrueColor PNG24:icon-maskable-512.png
+
+echo "icons regenerated (full-bleed squares) in $(pwd)"
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 9e7e75c6..ec0cfcd1 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -67,31 +67,20 @@ const applyEvent = (data: Response, event: HueLiveEvent): Response => {
return {
...data,
sensors: data.sensors.map((s) =>
- s.deviceId === event.deviceId
- ? { ...s, motionEnabled: event.enabled }
- : s,
+ s.deviceId === event.deviceId ? { ...s, motionEnabled: event.enabled } : s,
),
};
case "connectivity":
return {
...data,
sensors: data.sensors.map((s) =>
- s.deviceId === event.deviceId
- ? { ...s, connected: event.connected }
- : s,
+ s.deviceId === event.deviceId ? { ...s, connected: event.connected } : s,
),
};
}
};
-type View =
- | "temperature"
- | "energy"
- | "lights"
- | "motion"
- | "radar"
- | "history"
- | "settings";
+type View = "temperature" | "energy" | "lights" | "motion" | "radar" | "history" | "settings";
const VIEWS: { id: View; icon: string }[] = [
{ id: "temperature", icon: "thermostat" },
@@ -163,9 +152,7 @@ const App = () => {
}, [sensors]);
const outsideTemp = Math.min(
- ...outside
- .map((s) => s.temperature)
- .filter((t): t is number => t !== undefined),
+ ...outside.map((s) => s.temperature).filter((t): t is number => t !== undefined),
);
const theme = useTheme();
@@ -248,9 +235,7 @@ const App = () => {
cursor: "pointer",
backgroundColor: "transparent",
border: "none",
- color: active
- ? theme.colors.activity.on
- : theme.colors.text.muted,
+ color: active ? theme.colors.activity.on : theme.colors.text.muted,
padding: "6px",
transition: "color 0.15s ease",
}}
@@ -360,9 +345,7 @@ const App = () => {
)}
- {view === "history" && (
-
- )}
+ {view === "history" && }
{view === "settings" && (