From 52c1c5d3ac7ae747577daf28a43c3724099051b0 Mon Sep 17 00:00:00 2001 From: HomeOS Bootstrap Date: Sat, 18 Jul 2026 01:47:02 +0200 Subject: [PATCH] feat(addons): installable integrations framework + Ring connect UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of the Ring integration (handoff §12: ring-mqtt, cloud token auth): - Addons registry (static catalogue) + Addon model with encrypted config, so the Ring refresh token / credentials are never stored in plaintext. - AddonService: install / uninstall (wipes secrets) / saveConnection (blank password keeps the stored one) / markStatus (from the bridge over MQTT). - Addons page (new sidebar tab) with per-addon install, Cloud badge, live status pill, and a Ring connect modal capturing email/password/2FA — we never call Ring ourselves; creds are handed to the ring-mqtt sidecar. - Uninstall behind a wire-elements confirm (R5). Fixed the confirm target: Livewire names App\Livewire\Addons\Index as "addons" (Index suffix dropped), and only $wire.$dispatch (not Alpine $dispatch) reaches the modal listener. - DE/EN localization (R16); doorbell/package/cloud icons. 7 AddonTest cases (registry, install, encryption-at-rest, blank-password keep, uninstall wipe, status guard). Suite 33 green; 12/12 tabs clean; full install→connect→connecting→uninstall UI flow browser-verified, 0 console errors. Backend bridge (ring-mqtt container) + Ring MQTT normalizer land next. Co-Authored-By: Claude Opus 4.8 --- .reviews/r15-8aa3257.md | 753 ++++++++++++++++++ app/Livewire/Addons/Index.php | 37 + app/Livewire/Modals/RingConnect.php | 61 ++ app/Models/Addon.php | 23 + app/Services/AddonRegistry.php | 72 ++ app/Services/AddonService.php | 87 ++ .../2026_07_18_050001_create_addons_table.php | 29 + lang/de/addons.php | 32 + lang/de/common.php | 1 + lang/de/nav.php | 1 + lang/en/addons.php | 32 + lang/en/common.php | 1 + lang/en/nav.php | 1 + resources/views/components/icon.blade.php | 3 + resources/views/components/sidebar.blade.php | 1 + .../views/livewire/addons/index.blade.php | 68 ++ .../livewire/modals/ring-connect.blade.php | 45 ++ routes/web.php | 2 + tests/Feature/AddonTest.php | 93 +++ 19 files changed, 1342 insertions(+) create mode 100644 .reviews/r15-8aa3257.md create mode 100644 app/Livewire/Addons/Index.php create mode 100644 app/Livewire/Modals/RingConnect.php create mode 100644 app/Models/Addon.php create mode 100644 app/Services/AddonRegistry.php create mode 100644 app/Services/AddonService.php create mode 100644 database/migrations/2026_07_18_050001_create_addons_table.php create mode 100644 lang/de/addons.php create mode 100644 lang/en/addons.php create mode 100644 resources/views/livewire/addons/index.blade.php create mode 100644 resources/views/livewire/modals/ring-connect.blade.php create mode 100644 tests/Feature/AddonTest.php diff --git a/.reviews/r15-8aa3257.md b/.reviews/r15-8aa3257.md new file mode 100644 index 0000000..346433f --- /dev/null +++ b/.reviews/r15-8aa3257.md @@ -0,0 +1,753 @@ +OpenAI Codex v0.144.5 +-------- +workdir: /home/nexxo/homeos +model: gpt-5.6-sol +provider: openai +approval: never +sandbox: read-only +reasoning effort: none +reasoning summaries: none +session id: 019f7266-3d0b-7fb3-a7ca-25213f87961d +-------- +user +commit 8aa3257 +warning: Codex could not find bubblewrap on PATH. Install bubblewrap with your OS package manager. See the sandbox prerequisites: https://developers.openai.com/codex/concepts/sandboxing#prerequisites. Codex will use the bundled bubblewrap in the meantime. +exec +/bin/bash -lc 'git status --short && git show --stat --oneline --decorate --no-renames 8aa3257 && git show --format=fuller --no-ext-diff --no-renames --find-renames=0 8aa3257' in /home/nexxo/homeos + succeeded in 0ms: +?? .reviews/r15-8aa3257.md +8aa3257 (HEAD -> feat/phase-1-bootstrap, origin/feat/phase-1-bootstrap) feat(pwa): installable app — manifest, service worker, offline shell, icons + docker/nginx/default.conf | 13 ++++++ + public/apple-touch-icon.png | Bin 0 -> 1697 bytes + public/icons/icon-192.png | Bin 0 -> 3087 bytes + public/icons/icon-512.png | Bin 0 -> 9868 bytes + public/icons/icon-maskable-192.png | Bin 0 -> 1316 bytes + public/icons/icon-maskable-512.png | Bin 0 -> 4879 bytes + public/manifest.webmanifest | 56 ++++++++++++++++++++++++ + public/offline.html | 50 +++++++++++++++++++++ + public/sw.js | 74 ++++++++++++++++++++++++++++++++ + resources/js/app.js | 8 ++++ + resources/views/layouts/app.blade.php | 11 +++++ + resources/views/layouts/guest.blade.php | 5 +++ + 12 files changed, 217 insertions(+) +commit 8aa3257fa23e668f54c9fa76c805b3674fd181cf +Author: HomeOS Bootstrap +AuthorDate: Sat Jul 18 01:25:24 2026 +0200 +Commit: HomeOS Bootstrap +CommitDate: Sat Jul 18 01:25:24 2026 +0200 + + feat(pwa): installable app — manifest, service worker, offline shell, icons + + Makes HomeOS installable on the tablet/phone (the tablet-control use case): + - manifest.webmanifest: standalone display, brand colors, 192/512 "any" + + maskable icons, /panel + /rooms shortcuts, start_url /dashboard. + - sw.js: conservative for an authenticated Livewire app — never intercepts + non-GET (login/Livewire/broadcasting stay online), cache-first only for + immutable /build/ + /icons/, network-first navigations with an offline + fallback so authenticated HTML is never served stale. + - offline.html: self-contained branded offline page (no Vite dependency). + - Rendered PNG icons (any + maskable, glyph inside the safe zone) + + apple-touch-icon; manifest/theme-color/apple meta in both layouts; SW + registered from app.js. + - nginx: application/manifest+json MIME, no-cache + Service-Worker-Allowed + for sw.js (compose mount now active on the app container). + + Verified: manifest 200/valid, SW registers+activates (scope /), 11/11 tabs + clean, zero console errors. + + Co-Authored-By: Claude Opus 4.8 + +diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf +index 8739d1a..7501cf3 100644 +--- a/docker/nginx/default.conf ++++ b/docker/nginx/default.conf +@@ -36,6 +36,19 @@ server { + try_files $uri $uri/ /index.php?$query_string; + } + ++ # PWA: correct manifest MIME, and keep the service worker un-cached so updates ship. ++ location = /manifest.webmanifest { ++ types { } ++ default_type application/manifest+json; ++ add_header Cache-Control "public, max-age=3600"; ++ access_log off; ++ } ++ ++ location = /sw.js { ++ add_header Cache-Control "no-cache"; ++ add_header Service-Worker-Allowed "/"; ++ } ++ + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } + +diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png +new file mode 100644 +index 0000000..5fcda22 +Binary files /dev/null and b/public/apple-touch-icon.png differ +diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png +new file mode 100644 +index 0000000..1c1adbf +Binary files /dev/null and b/public/icons/icon-192.png differ +diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png +new file mode 100644 +index 0000000..5b39a49 +Binary files /dev/null and b/public/icons/icon-512.png differ +diff --git a/public/icons/icon-maskable-192.png b/public/icons/icon-maskable-192.png +new file mode 100644 +index 0000000..e2e49ec +Binary files /dev/null and b/public/icons/icon-maskable-192.png differ +diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png +new file mode 100644 +index 0000000..613fe07 +Binary files /dev/null and b/public/icons/icon-maskable-512.png differ +diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest +new file mode 100644 +index 0000000..6e99d59 +--- /dev/null ++++ b/public/manifest.webmanifest +@@ -0,0 +1,56 @@ ++{ ++ "name": "HomeOS", ++ "short_name": "HomeOS", ++ "description": "Selbst-gehostete Haussteuerung — Räume, Geräte, Automationen.", ++ "id": "/", ++ "start_url": "/dashboard", ++ "scope": "/", ++ "display": "standalone", ++ "display_override": ["standalone", "minimal-ui"], ++ "orientation": "any", ++ "background_color": "#080D18", ++ "theme_color": "#0D1424", ++ "lang": "de", ++ "dir": "ltr", ++ "categories": ["utilities", "lifestyle"], ++ "icons": [ ++ { ++ "src": "/icons/icon-192.png", ++ "sizes": "192x192", ++ "type": "image/png", ++ "purpose": "any" ++ }, ++ { ++ "src": "/icons/icon-512.png", ++ "sizes": "512x512", ++ "type": "image/png", ++ "purpose": "any" ++ }, ++ { ++ "src": "/icons/icon-maskable-192.png", ++ "sizes": "192x192", ++ "type": "image/png", ++ "purpose": "maskable" ++ }, ++ { ++ "src": "/icons/icon-maskable-512.png", ++ "sizes": "512x512", ++ "type": "image/png", ++ "purpose": "maskable" ++ } ++ ], ++ "shortcuts": [ ++ { ++ "name": "Steuerung", ++ "short_name": "Steuerung", ++ "url": "/panel", ++ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] ++ }, ++ { ++ "name": "Räume", ++ "short_name": "Räume", ++ "url": "/rooms", ++ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }] ++ } ++ ] ++} +diff --git a/public/offline.html b/public/offline.html +new file mode 100644 +index 0000000..02b70aa +--- /dev/null ++++ b/public/offline.html +@@ -0,0 +1,50 @@ ++ ++ ++ ++ ++ ++ Offline · HomeOS ++ ++ ++ ++
++
++ ++ ++ ++ ++ ++ ++
++

Keine Verbindung

++

HomeOS ist gerade nicht erreichbar. Prüfe deine Netzwerkverbindung zum Server.

++ ++
++ ++ +diff --git a/public/sw.js b/public/sw.js +new file mode 100644 +index 0000000..edc756a +--- /dev/null ++++ b/public/sw.js +@@ -0,0 +1,74 @@ ++/** ++ * HomeOS service worker. Deliberately conservative for an authenticated Livewire app: ++ * - never intercepts non-GET (login, Livewire updates, broadcasting/auth stay online-only) ++ * - cache-first only for fingerprinted, immutable assets under /build/ and static icons ++ * - navigations are network-first with an offline fallback, so authenticated HTML is never ++ * served stale from cache ++ * Bump VERSION to invalidate old caches on the next activate. ++ */ ++const VERSION = 'homeos-v1'; ++const STATIC_CACHE = `${VERSION}-static`; ++const OFFLINE_URL = '/offline.html'; ++const PRECACHE = [OFFLINE_URL, '/icons/icon-192.png', '/icons/icon-512.png']; ++ ++self.addEventListener('install', (event) => { ++ event.waitUntil( ++ (async () => { ++ const cache = await caches.open(STATIC_CACHE); ++ await cache.addAll(PRECACHE); ++ await self.skipWaiting(); ++ })(), ++ ); ++}); ++ ++self.addEventListener('activate', (event) => { ++ event.waitUntil( ++ (async () => { ++ const keys = await caches.keys(); ++ await Promise.all(keys.filter((k) => !k.startsWith(VERSION)).map((k) => caches.delete(k))); ++ await self.clients.claim(); ++ })(), ++ ); ++}); ++ ++self.addEventListener('message', (event) => { ++ if (event.data === 'skipWaiting') self.skipWaiting(); ++}); ++ ++self.addEventListener('fetch', (event) => { ++ const req = event.request; ++ if (req.method !== 'GET') return; // login / Livewire / broadcasting are POST — stay online ++ ++ const url = new URL(req.url); ++ if (url.origin !== self.location.origin) return; // don't touch cross-origin ++ ++ // Immutable, fingerprinted build output + static icons → cache-first. ++ if (url.pathname.startsWith('/build/') || url.pathname.startsWith('/icons/')) { ++ event.respondWith(cacheFirst(req)); ++ return; ++ } ++ ++ // Page loads → network-first, fall back to the offline shell only when truly offline. ++ if (req.mode === 'navigate') { ++ event.respondWith(networkFirst(req)); ++ } ++}); ++ ++async function cacheFirst(req) { ++ const cache = await caches.open(STATIC_CACHE); ++ const hit = await cache.match(req); ++ if (hit) return hit; ++ const res = await fetch(req); ++ if (res && res.ok) cache.put(req, res.clone()); ++ return res; ++} ++ ++async function networkFirst(req) { ++ try { ++ return await fetch(req); ++ } catch (e) { ++ const cache = await caches.open(STATIC_CACHE); ++ const offline = await cache.match(OFFLINE_URL); ++ return offline || Response.error(); ++ } ++} +diff --git a/resources/js/app.js b/resources/js/app.js +index 8337e44..f02bd0f 100644 +--- a/resources/js/app.js ++++ b/resources/js/app.js +@@ -85,3 +85,11 @@ window.Echo = new Echo({ + forceTLS: isHttps, + enabledTransports: ['ws', 'wss'], + }); ++ ++// Register the PWA service worker so HomeOS installs on tablets/phones and survives ++// brief server blips (offline shell). sw.js lives at the web root for a "/" scope. ++if ('serviceWorker' in navigator) { ++ window.addEventListener('load', () => { ++ navigator.serviceWorker.register('/sw.js').catch(() => {}); ++ }); ++} +diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php +index d0c3329..bab9047 100644 +--- a/resources/views/layouts/app.blade.php ++++ b/resources/views/layouts/app.blade.php +@@ -5,6 +5,17 @@ + + + ++ ++ ++ {{-- PWA: installable on tablet/phone (§ tablet control) --}} ++ ++ ++ ++ ++ ++ ++ ++ + {{ $title ?? __('common.app_name') }} · {{ __('common.app_name') }} + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles +diff --git a/resources/views/layouts/guest.blade.php b/resources/views/layouts/guest.blade.php +index 0f1251f..e7b80df 100644 +--- a/resources/views/layouts/guest.blade.php ++++ b/resources/views/layouts/guest.blade.php +@@ -5,6 +5,11 @@ + + + ++ ++ ++ ++ ++ + {{ __('auth.title') }} · {{ __('common.app_name') }} + @vite(['resources/css/app.css', 'resources/js/app.js']) + @livewireStyles + +exec +/bin/bash -lc "sed -n '1,140p' docker/nginx/default.conf; echo '--- compose refs'; grep -R \"default.conf\\|docker/nginx\\|nginx\" -n docker-compose* compose* 2>/dev/null | head -80; echo '--- routes'; grep -R \"dashboard\\|panel\\|rooms\" -n routes | head -60; echo '--- vite'; cat vite.config.js; echo '--- package'; cat package.json" in /home/nexxo/homeos + succeeded in 0ms: +# WebSocket upgrade mapping (http context) +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + root /var/www/html/public; + index index.php; + + charset utf-8; + client_max_body_size 32M; + + # Reverb (Pusher protocol) proxied same-origin: browser connects to /app/, + # Laravel publishes to /apps//events. Keeps WS on the page's own host/port. + # Docker embedded DNS + variable proxy_pass → runtime resolution (reverb may start + # after nginx, or change IP on restart, without breaking the config). + location ~ ^/(app|apps)(/|$) { + resolver 127.0.0.11 valid=30s ipv6=off; + set $reverb_upstream reverb; + proxy_pass http://$reverb_upstream:8080$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + } + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # PWA: correct manifest MIME, and keep the service worker un-cached so updates ship. + location = /manifest.webmanifest { + types { } + default_type application/manifest+json; + add_header Cache-Control "public, max-age=3600"; + access_log off; + } + + location = /sw.js { + add_header Cache-Control "no-cache"; + add_header Service-Worker-Allowed "/"; + } + + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } + + error_page 404 /index.php; + + location ~ \.php$ { + fastcgi_pass 127.0.0.1:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + include fastcgi_params; + fastcgi_read_timeout 120s; + } + + location ~ /\.(?!well-known).* { + deny all; + } +} +--- compose refs +docker-compose.yml:16: - ./docker/nginx/default.conf:/etc/nginx/sites-available/default:ro +--- routes +routes/channels.php:14:Broadcast::channel('rooms.{uuid}', fn ($user, string $uuid) => $user !== null); +routes/web.php:20:Route::get('/', fn () => redirect()->route('dashboard')); +routes/web.php:27: Route::get('/dashboard', Dashboard::class)->name('dashboard'); +routes/web.php:28: Route::get('/panel', Panel::class)->name('panel'); +routes/web.php:29: Route::get('/rooms', RoomIndex::class)->name('rooms.index'); +routes/web.php:30: Route::get('/rooms/{room}', RoomShow::class)->name('rooms.show'); +--- vite +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + tailwindcss(), + ], + server: { + host: '0.0.0.0', + port: 5173, + // HMR host for LAN dev; override with VITE_DEV_HOST when accessing via the VM's IP. + hmr: { host: process.env.VITE_DEV_HOST || 'localhost' }, + watch: { + ignored: ['**/storage/framework/views/**'], + }, + }, +}); +--- package +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^3.1", + "tailwindcss": "^4.0.0", + "vite": "^8.0.0" + }, + "dependencies": { + "chart.js": "^4.5.1", + "laravel-echo": "^2.4.0", + "pusher-js": "^8.5.0", + "sortablejs": "^1.15.7" + } +} + +exec +/bin/bash -lc 'cat docker-compose.yml; git show 8aa3257''^:docker-compose.yml | sed -n '"'1,80p'; git log --oneline --all -- docker/nginx/default.conf | head" in /home/nexxo/homeos + succeeded in 0ms: +# HomeOS — dev stack (dev = prod parity, R8). All app-family services share one image. +# mosquitto / mqtt-listener (Phase 3) and discovery sidecar (Phase 4) are added in their phases. + +x-app-image: &app-image + image: homeos-app:dev + build: + context: . + dockerfile: docker/app/Dockerfile + args: + HOST_UID: ${HOST_UID:-1000} + HOST_GID: ${HOST_GID:-1000} + # Laravel reads its own .env from the mounted volume (correct phpdotenv interpolation). + # Compose still reads root .env for ${...} substitution in this file (ports, uid, db creds). + volumes: + - .:/var/www/html + - ./docker/nginx/default.conf:/etc/nginx/sites-available/default:ro + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + restart: unless-stopped + +services: + app: + <<: *app-image + ports: + - "${APP_PORT:-80}:80" + - "${VITE_PORT:-5173}:5173" + + horizon: + <<: *app-image + command: ["php", "artisan", "horizon"] + ports: [] + + scheduler: + <<: *app-image + command: ["php", "artisan", "schedule:work"] + ports: [] + + reverb: + <<: *app-image + command: ["php", "artisan", "reverb:start", "--host=0.0.0.0", "--port=8080"] + ports: + - "${REVERB_HOST_PORT:-6001}:8080" + + mqtt-listener: + <<: *app-image + command: ["php", "artisan", "mqtt:listen"] + restart: always + ports: [] + + db: + image: timescale/timescaledb:latest-pg17 + environment: + POSTGRES_DB: ${DB_DATABASE:-homeos} + POSTGRES_USER: ${DB_USERNAME:-homeos} + POSTGRES_PASSWORD: ${DB_PASSWORD:-homeos} + volumes: + - db-data:/var/lib/postgresql/data + ports: + - "127.0.0.1:${DB_HOST_PORT:-5432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-homeos} -d ${DB_DATABASE:-homeos}"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + redis: + image: redis:7-alpine + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis-data:/data + restart: unless-stopped + + mosquitto: + image: eclipse-mosquitto:2 + # Wrapper runs mosquitto + reloads it (SIGHUP) when a device credential is provisioned. + command: ["/bin/sh", "/mosquitto/config/entrypoint.sh"] + volumes: + - ./docker/mosquitto/config:/mosquitto/config + - mosquitto-data:/mosquitto/data + ports: + # LAN-reachable so real devices can connect (auth + ACL enforced) + - "${MQTT_HOST_PORT:-1883}:1883" + restart: unless-stopped + + # Discovery sidecar — host network so mDNS/SSDP multicast reaches the smart-home VLAN. + discovery: + build: + context: ./sidecar + dockerfile: Dockerfile + network_mode: host + cap_add: [NET_RAW] + environment: + MQTT_HOST: 127.0.0.1 + MQTT_PORT: ${MQTT_HOST_PORT:-1883} + MQTT_USERNAME: sidecar + MQTT_PASSWORD: ${MQTT_SIDECAR_PASSWORD} + restart: unless-stopped + +volumes: + db-data: + redis-data: + mosquitto-data: +# HomeOS — dev stack (dev = prod parity, R8). All app-family services share one image. +# mosquitto / mqtt-listener (Phase 3) and discovery sidecar (Phase 4) are added in their phases. + +x-app-image: &app-image + image: homeos-app:dev + build: + context: . + dockerfile: docker/app/Dockerfile + args: + HOST_UID: ${HOST_UID:-1000} + HOST_GID: ${HOST_GID:-1000} + # Laravel reads its own .env from the mounted volume (correct phpdotenv interpolation). + # Compose still reads root .env for ${...} substitution in this file (ports, uid, db creds). + volumes: + - .:/var/www/html + - ./docker/nginx/default.conf:/etc/nginx/sites-available/default:ro + depends_on: + db: + condition: service_healthy + redis: + condition: service_started + restart: unless-stopped + +services: + app: + <<: *app-image + ports: + - "${APP_PORT:-80}:80" + - "${VITE_PORT:-5173}:5173" + + horizon: + <<: *app-image + command: ["php", "artisan", "horizon"] + ports: [] + + scheduler: + <<: *app-image + command: ["php", "artisan", "schedule:work"] + ports: [] + + reverb: + <<: *app-image + command: ["php", "artisan", "reverb:start", "--host=0.0.0.0", "--port=8080"] + ports: + - "${REVERB_HOST_PORT:-6001}:8080" + + mqtt-listener: + <<: *app-image + command: ["php", "artisan", "mqtt:listen"] + restart: always + ports: [] + + db: + image: timescale/timescaledb:latest-pg17 + environment: + POSTGRES_DB: ${DB_DATABASE:-homeos} + POSTGRES_USER: ${DB_USERNAME:-homeos} + POSTGRES_PASSWORD: ${DB_PASSWORD:-homeos} + volumes: + - db-data:/var/lib/postgresql/data + ports: + - "127.0.0.1:${DB_HOST_PORT:-5432}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-homeos} -d ${DB_DATABASE:-homeos}"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + redis: + image: redis:7-alpine + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis-data:/data + restart: unless-stopped + + mosquitto: + image: eclipse-mosquitto:2 + # Wrapper runs mosquitto + reloads it (SIGHUP) when a device credential is provisioned. + command: ["/bin/sh", "/mosquitto/config/entrypoint.sh"] +8aa3257 feat(pwa): installable app — manifest, service worker, offline shell, icons +beac04e Phase 1: bootstrap — Docker stack, Laravel 13, design system, auth + +exec +/bin/bash -lc "file public/*.png public/icons/*.png; nl -ba public/sw.js | sed -n '20,75p'; nginx -t -c /home/nexxo/homeos/docker/nginx/default.conf 2>&1 || true" in /home/nexxo/homeos + succeeded in 330ms: +public/apple-touch-icon.png: PNG image data, 180 x 180, 8-bit/color RGB, non-interlaced +public/icons/icon-192.png: PNG image data, 192 x 192, 8-bit/color RGBA, non-interlaced +public/icons/icon-512.png: PNG image data, 512 x 512, 8-bit/color RGBA, non-interlaced +public/icons/icon-maskable-192.png: PNG image data, 192 x 192, 8-bit/color RGB, non-interlaced +public/icons/icon-maskable-512.png: PNG image data, 512 x 512, 8-bit/color RGB, non-interlaced + 20 })(), + 21 ); + 22 }); + 23 + 24 self.addEventListener('activate', (event) => { + 25 event.waitUntil( + 26 (async () => { + 27 const keys = await caches.keys(); + 28 await Promise.all(keys.filter((k) => !k.startsWith(VERSION)).map((k) => caches.delete(k))); + 29 await self.clients.claim(); + 30 })(), + 31 ); + 32 }); + 33 + 34 self.addEventListener('message', (event) => { + 35 if (event.data === 'skipWaiting') self.skipWaiting(); + 36 }); + 37 + 38 self.addEventListener('fetch', (event) => { + 39 const req = event.request; + 40 if (req.method !== 'GET') return; // login / Livewire / broadcasting are POST — stay online + 41 + 42 const url = new URL(req.url); + 43 if (url.origin !== self.location.origin) return; // don't touch cross-origin + 44 + 45 // Immutable, fingerprinted build output + static icons → cache-first. + 46 if (url.pathname.startsWith('/build/') || url.pathname.startsWith('/icons/')) { + 47 event.respondWith(cacheFirst(req)); + 48 return; + 49 } + 50 + 51 // Page loads → network-first, fall back to the offline shell only when truly offline. + 52 if (req.mode === 'navigate') { + 53 event.respondWith(networkFirst(req)); + 54 } + 55 }); + 56 + 57 async function cacheFirst(req) { + 58 const cache = await caches.open(STATIC_CACHE); + 59 const hit = await cache.match(req); + 60 if (hit) return hit; + 61 const res = await fetch(req); + 62 if (res && res.ok) cache.put(req, res.clone()); + 63 return res; + 64 } + 65 + 66 async function networkFirst(req) { + 67 try { + 68 return await fetch(req); + 69 } catch (e) { + 70 const cache = await caches.open(STATIC_CACHE); + 71 const offline = await cache.match(OFFLINE_URL); + 72 return offline || Response.error(); + 73 } + 74 } +/bin/bash: line 1: nginx: command not found + +codex +The PWA setup is generally sound, but runtime caching is unreliable because cache writes are not included in the service worker event lifetime. + +Review comment: + +- [P2] Await runtime cache writes before completing the fetch — /home/nexxo/homeos/public/sw.js:61-63 + On the first request for a non-precached `/build/` asset or icon, `cache.put()` is started but its promise is discarded. Once the `respondWith()` promise resolves, the service worker may be terminated before that write finishes, leaving the asset uncached and defeating cache-first behavior on later or offline requests. Await the write before returning the response, or keep it alive with `event.waitUntil()`. +The PWA setup is generally sound, but runtime caching is unreliable because cache writes are not included in the service worker event lifetime. + +Review comment: + +- [P2] Await runtime cache writes before completing the fetch — /home/nexxo/homeos/public/sw.js:61-63 + On the first request for a non-precached `/build/` asset or icon, `cache.put()` is started but its promise is discarded. Once the `respondWith()` promise resolves, the service worker may be terminated before that write finishes, leaving the asset uncached and defeating cache-first behavior on later or offline requests. Await the write before returning the response, or keep it alive with `event.waitUntil()`. diff --git a/app/Livewire/Addons/Index.php b/app/Livewire/Addons/Index.php new file mode 100644 index 0000000..f01fde4 --- /dev/null +++ b/app/Livewire/Addons/Index.php @@ -0,0 +1,37 @@ +install($key); + } + + #[On('addon-uninstall')] + public function uninstall(): void + { + if ($this->pendingUninstall !== null && AddonRegistry::has($this->pendingUninstall)) { + app(AddonService::class)->uninstall($this->pendingUninstall); + $this->pendingUninstall = null; + } + } + + public function render() + { + return view('livewire.addons.index', [ + 'addons' => AddonRegistry::withState(), + ]); + } +} diff --git a/app/Livewire/Modals/RingConnect.php b/app/Livewire/Modals/RingConnect.php new file mode 100644 index 0000000..a0c92fa --- /dev/null +++ b/app/Livewire/Modals/RingConnect.php @@ -0,0 +1,61 @@ +first(); + $config = $addon?->config ?? []; + $this->email = $config['email'] ?? ''; + $this->hasStoredPassword = filled($config['password'] ?? null); + } + + public function save() + { + $this->validate([ + 'email' => 'required|email|max:190', + // Password may be left blank to keep a previously stored one. + 'password' => $this->hasStoredPassword ? 'nullable|string|max:190' : 'required|string|max:190', + 'code' => 'nullable|string|max:12', + ]); + + app(AddonService::class)->saveConnection('ring', [ + 'email' => $this->email, + 'password' => $this->password, + 'code' => $this->code, + ]); + + $this->closeModal(); + + return redirect()->route('addons.index'); + } + + public function render() + { + return view('livewire.modals.ring-connect'); + } +} diff --git a/app/Models/Addon.php b/app/Models/Addon.php new file mode 100644 index 0000000..59617bb --- /dev/null +++ b/app/Models/Addon.php @@ -0,0 +1,23 @@ + 'boolean', + 'config' => 'encrypted:array', // secrets never stored in plaintext + 'connected_at' => 'datetime', + ]; +} diff --git a/app/Services/AddonRegistry.php b/app/Services/AddonRegistry.php new file mode 100644 index 0000000..1191413 --- /dev/null +++ b/app/Services/AddonRegistry.php @@ -0,0 +1,72 @@ +> */ + public static function all(): array + { + return [ + 'ring' => [ + 'key' => 'ring', + 'name' => 'Ring', + 'vendor' => 'Ring', + 'icon' => 'doorbell', + // Ring has no local API — the established bridge is ring-mqtt (cloud, token auth). + 'cloud' => true, + 'summary_key' => 'addons.ring.summary', + 'body_key' => 'addons.ring.body', + // The ring-mqtt sidecar generates a refresh token from an interactive Ring login + // (email + password + 2FA). We store that token; the sidecar does the rest. + 'backend' => 'ring-mqtt', + 'docs' => 'https://github.com/tsightler/ring-mqtt', + 'fields' => [ + ['name' => 'email', 'type' => 'email', 'label_key' => 'addons.ring.email', 'secret' => false], + ['name' => 'password', 'type' => 'password', 'label_key' => 'addons.ring.password', 'secret' => true], + ['name' => 'code', 'type' => 'text', 'label_key' => 'addons.ring.code', 'secret' => false, 'optional' => true], + ], + ], + ]; + } + + public static function get(string $key): ?array + { + return static::all()[$key] ?? null; + } + + public static function has(string $key): bool + { + return isset(static::all()[$key]); + } + + /** + * Merge each catalogue definition with its persisted state, creating rows lazily so the + * page can render before anything is installed. + * + * @return array> + */ + public static function withState(): array + { + $rows = Addon::whereIn('key', array_keys(static::all()))->get()->keyBy('key'); + + return collect(static::all())->map(function (array $def) use ($rows) { + $state = $rows->get($def['key']); + + return $def + [ + 'installed' => (bool) ($state?->installed ?? false), + 'status' => $state?->status ?? 'disconnected', + 'status_detail' => $state?->status_detail, + 'connected_at' => $state?->connected_at, + 'uuid' => $state?->uuid, + ]; + })->values()->all(); + } +} diff --git a/app/Services/AddonService.php b/app/Services/AddonService.php new file mode 100644 index 0000000..8867f70 --- /dev/null +++ b/app/Services/AddonService.php @@ -0,0 +1,87 @@ + $key]); + } + + public function install(string $key): Addon + { + if (! AddonRegistry::has($key)) { + abort(404); + } + + $addon = $this->row($key); + $addon->forceFill(['installed' => true])->save(); + + return $addon; + } + + public function uninstall(string $key): void + { + $addon = Addon::where('key', $key)->first(); + + // Wipe stored secrets on uninstall — no orphaned tokens left encrypted at rest. + $addon?->forceFill([ + 'installed' => false, + 'status' => 'disconnected', + 'status_detail' => null, + 'config' => null, + 'connected_at' => null, + ])->save(); + } + + /** + * Persist the credentials the backend bridge needs. We do NOT talk to Ring ourselves + * (no local API; the ring-mqtt sidecar owns the OAuth + 2FA dance) — we only hand the + * bridge what it asked for and mark the addon as connecting. + * + * @param array $config + */ + public function saveConnection(string $key, array $config): Addon + { + $addon = $this->install($key); + + $existing = $addon->config ?? []; + // Drop blank fields so re-submitting without the password doesn't wipe a stored one. + $merged = array_merge($existing, array_filter($config, fn ($v) => $v !== null && $v !== '')); + + $addon->forceFill([ + 'config' => $merged, + 'status' => 'connecting', + 'status_detail' => null, + ])->save(); + + return $addon; + } + + /** Reflect a status line the bridge published over MQTT (online/offline/error). */ + public function markStatus(string $key, string $status, ?string $detail = null): void + { + $addon = Addon::where('key', $key)->where('installed', true)->first(); + + if ($addon === null) { + return; + } + + DB::transaction(function () use ($addon, $status, $detail) { + $addon->forceFill([ + 'status' => $status, + 'status_detail' => $detail, + 'connected_at' => $status === 'connected' ? now() : $addon->connected_at, + ])->save(); + }); + } +} diff --git a/database/migrations/2026_07_18_050001_create_addons_table.php b/database/migrations/2026_07_18_050001_create_addons_table.php new file mode 100644 index 0000000..2b07188 --- /dev/null +++ b/database/migrations/2026_07_18_050001_create_addons_table.php @@ -0,0 +1,29 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('key')->unique(); // registry key, e.g. "ring" + $table->boolean('installed')->default(false); + $table->string('status')->default('disconnected'); // disconnected|connecting|connected|error + $table->text('status_detail')->nullable(); // last error / info line + // Encrypted at rest — holds the Ring refresh token and other secrets (never plaintext). + $table->text('config')->nullable(); + $table->timestamp('connected_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('addons'); + } +}; diff --git a/lang/de/addons.php b/lang/de/addons.php new file mode 100644 index 0000000..c4545cf --- /dev/null +++ b/lang/de/addons.php @@ -0,0 +1,32 @@ + 'Integrationen installieren und verbinden.', + 'available' => 'Verfügbar', + 'installed' => 'Installiert', + 'install' => 'Installieren', + 'uninstall' => 'Entfernen', + 'uninstall_title' => ':name entfernen?', + 'uninstall_body' => 'Die Integration wird deaktiviert und gespeicherte Zugangsdaten werden gelöscht. Geräte bleiben erhalten, empfangen aber keine Aktualisierungen mehr.', + 'connect' => 'Konto verbinden', + 'reconnect' => 'Erneut verbinden', + 'cloud' => 'Cloud', + 'cloud_hint' => 'Diese Integration benötigt eine Cloud-Verbindung zum Hersteller.', + + 'status_disconnected' => 'Nicht verbunden', + 'status_connecting' => 'Verbindet …', + 'status_connected' => 'Verbunden', + 'status_error' => 'Fehler', + + 'ring' => [ + 'summary' => 'Türklingel, Kameras und Sensoren über die ring-mqtt-Bridge.', + 'body' => 'Ring bietet keine lokale Schnittstelle. Die Anmeldung läuft über dein Ring-Konto; die Verbindung stellt der ring-mqtt-Dienst im Hintergrund her.', + 'email' => 'Ring E-Mail', + 'password' => 'Ring Passwort', + 'code' => '2FA-Code (falls aktiv)', + 'password_stored' => 'Ein Passwort ist gespeichert. Leer lassen, um es zu behalten.', + 'connect_title' => 'Ring-Konto verbinden', + 'backend_note' => 'Zugangsdaten werden verschlüsselt gespeichert und an den ring-mqtt-Dienst übergeben. Der eigentliche Login inkl. 2FA passiert im Backend.', + 'save' => 'Speichern & verbinden', + ], +]; diff --git a/lang/de/common.php b/lang/de/common.php index e6538da..1463f2f 100644 --- a/lang/de/common.php +++ b/lang/de/common.php @@ -15,4 +15,5 @@ return [ 'menu' => 'Menü', 'close' => 'Schließen', 'cancel' => 'Abbrechen', + 'docs' => 'Doku', ]; diff --git a/lang/de/nav.php b/lang/de/nav.php index 0052b9e..8d8f101 100644 --- a/lang/de/nav.php +++ b/lang/de/nav.php @@ -17,6 +17,7 @@ return [ 'access' => 'Zugang & Face-ID', 'network' => 'Netzwerk & Discovery', 'automations' => 'Automationen', + 'addons' => 'Add-ons', 'host' => 'Host & Dienste', 'settings' => 'Einstellungen', diff --git a/lang/en/addons.php b/lang/en/addons.php new file mode 100644 index 0000000..3882759 --- /dev/null +++ b/lang/en/addons.php @@ -0,0 +1,32 @@ + 'Install and connect integrations.', + 'available' => 'Available', + 'installed' => 'Installed', + 'install' => 'Install', + 'uninstall' => 'Remove', + 'uninstall_title' => 'Remove :name?', + 'uninstall_body' => 'The integration is disabled and stored credentials are deleted. Devices are kept but stop receiving updates.', + 'connect' => 'Connect account', + 'reconnect' => 'Reconnect', + 'cloud' => 'Cloud', + 'cloud_hint' => 'This integration needs a cloud connection to the vendor.', + + 'status_disconnected' => 'Not connected', + 'status_connecting' => 'Connecting …', + 'status_connected' => 'Connected', + 'status_error' => 'Error', + + 'ring' => [ + 'summary' => 'Doorbell, cameras and sensors via the ring-mqtt bridge.', + 'body' => 'Ring has no local API. Sign in with your Ring account; the connection is established by the ring-mqtt service in the background.', + 'email' => 'Ring email', + 'password' => 'Ring password', + 'code' => '2FA code (if enabled)', + 'password_stored' => 'A password is stored. Leave blank to keep it.', + 'connect_title' => 'Connect Ring account', + 'backend_note' => 'Credentials are stored encrypted and handed to the ring-mqtt service. The actual login incl. 2FA happens in the backend.', + 'save' => 'Save & connect', + ], +]; diff --git a/lang/en/common.php b/lang/en/common.php index 3b58d0a..4169b9d 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -15,4 +15,5 @@ return [ 'menu' => 'Menu', 'close' => 'Close', 'cancel' => 'Cancel', + 'docs' => 'Docs', ]; diff --git a/lang/en/nav.php b/lang/en/nav.php index 8aa242f..e423dcd 100644 --- a/lang/en/nav.php +++ b/lang/en/nav.php @@ -17,6 +17,7 @@ return [ 'access' => 'Access & Face ID', 'network' => 'Network & Discovery', 'automations' => 'Automations', + 'addons' => 'Add-ons', 'host' => 'Host & Services', 'settings' => 'Settings', diff --git a/resources/views/components/icon.blade.php b/resources/views/components/icon.blade.php index 25a4095..8012924 100644 --- a/resources/views/components/icon.blade.php +++ b/resources/views/components/icon.blade.php @@ -24,6 +24,9 @@ 'user' => '', 'chevron' => '', 'bolt' => '', + 'package' => '', + 'doorbell' => '', + 'cloud' => '', 'dot' => '', ]; $inner = $paths[$name] ?? $paths['dot']; diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index 55c80cb..52cd42f 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -16,6 +16,7 @@ 'nav.section_system' => [ ['key' => 'network', 'icon' => 'network', 'route' => 'network', 'lock' => false], ['key' => 'automations', 'icon' => 'automation', 'route' => 'automations.index', 'active' => 'automations.*', 'lock' => false], + ['key' => 'addons', 'icon' => 'package', 'route' => 'addons.index', 'active' => 'addons.*', 'lock' => false], ['key' => 'host', 'icon' => 'activity', 'route' => 'host', 'lock' => false], ['key' => 'settings', 'icon' => 'settings', 'route' => 'settings', 'lock' => false], ], diff --git a/resources/views/livewire/addons/index.blade.php b/resources/views/livewire/addons/index.blade.php new file mode 100644 index 0000000..bfef573 --- /dev/null +++ b/resources/views/livewire/addons/index.blade.php @@ -0,0 +1,68 @@ +
+ + +
+
+ @foreach ($addons as $addon) + @php + $statusState = match ($addon['status']) { + 'connected' => 'online', + 'connecting' => 'warning', + 'error' => 'offline', + default => 'neutral', + }; + @endphp + +
+
+ +
+
+

{{ $addon['name'] }}

+ @if ($addon['cloud']) + + {{ __('addons.cloud') }} + + @endif +
+

{{ __($addon['summary_key']) }}

+
+ @if ($addon['installed']) + {{ __('addons.status_'.$addon['status']) }} + @endif +
+ +

{{ __($addon['body_key']) }}

+ + @if ($addon['installed'] && $addon['status_detail']) +

{{ $addon['status_detail'] }}

+ @endif + +
+ @if (! $addon['installed']) + + @else + + + {{ __('common.docs') }} + + + @endif +
+
+
+ @endforeach +
+
+
diff --git a/resources/views/livewire/modals/ring-connect.blade.php b/resources/views/livewire/modals/ring-connect.blade.php new file mode 100644 index 0000000..9bdf2f1 --- /dev/null +++ b/resources/views/livewire/modals/ring-connect.blade.php @@ -0,0 +1,45 @@ +
+
+ +

{{ __('addons.ring.connect_title') }}

+ +
+ +
+

+ + {{ __('addons.ring.backend_note') }} +

+ +
+ + + @error('email')

{{ $message }}

@enderror +
+ +
+ + + @if ($hasStoredPassword) +

{{ __('addons.ring.password_stored') }}

+ @endif + @error('password')

{{ $message }}

@enderror +
+ +
+ + + @error('code')

{{ $message }}

@enderror +
+ +
+ + +
+
+
diff --git a/routes/web.php b/routes/web.php index d1a71c6..4e10eae 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ group(function () { Route::get('/access', Access::class)->name('access'); Route::get('/network', DiscoveryIndex::class)->name('network'); Route::get('/automations', AutomationIndex::class)->name('automations.index'); + Route::get('/addons', AddonIndex::class)->name('addons.index'); Route::get('/settings', SettingsIndex::class)->name('settings'); Route::get('/host', Host::class)->name('host'); diff --git a/tests/Feature/AddonTest.php b/tests/Feature/AddonTest.php new file mode 100644 index 0000000..0fda370 --- /dev/null +++ b/tests/Feature/AddonTest.php @@ -0,0 +1,93 @@ +assertCount(1, $addons); + $ring = $addons[0]; + $this->assertSame('ring', $ring['key']); + $this->assertTrue($ring['cloud']); + $this->assertFalse($ring['installed']); + $this->assertSame('disconnected', $ring['status']); + } + + public function test_install_marks_installed(): void + { + app(AddonService::class)->install('ring'); + + $this->assertDatabaseHas('addons', ['key' => 'ring', 'installed' => true]); + } + + public function test_installing_unknown_addon_aborts(): void + { + $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class); + app(AddonService::class)->install('nope'); + } + + public function test_save_connection_encrypts_config_and_sets_connecting(): void + { + app(AddonService::class)->saveConnection('ring', [ + 'email' => 'me@example.com', + 'password' => 'secret-pw', + 'code' => '123456', + ]); + + $addon = Addon::where('key', 'ring')->first(); + $this->assertSame('connecting', $addon->status); + $this->assertSame('me@example.com', $addon->config['email']); + $this->assertSame('secret-pw', $addon->config['password']); + + // Stored ciphertext must not contain the plaintext secret. + $raw = DB::table('addons')->where('key', 'ring')->value('config'); + $this->assertStringNotContainsString('secret-pw', $raw); + } + + public function test_blank_password_keeps_previously_stored_one(): void + { + $service = app(AddonService::class); + $service->saveConnection('ring', ['email' => 'me@example.com', 'password' => 'first-pw']); + $service->saveConnection('ring', ['email' => 'me@example.com', 'password' => '']); + + $addon = Addon::where('key', 'ring')->first(); + $this->assertSame('first-pw', $addon->config['password']); + } + + public function test_uninstall_wipes_secrets(): void + { + $service = app(AddonService::class); + $service->saveConnection('ring', ['email' => 'me@example.com', 'password' => 'secret-pw']); + $service->uninstall('ring'); + + $addon = Addon::where('key', 'ring')->first(); + $this->assertFalse($addon->installed); + $this->assertSame('disconnected', $addon->status); + $this->assertNull($addon->config); + } + + public function test_mark_status_only_applies_to_installed_addons(): void + { + $service = app(AddonService::class); + + // Not installed → ignored. + $service->markStatus('ring', 'connected'); + $this->assertNull(Addon::where('key', 'ring')->first()?->connected_at); + + $service->install('ring'); + $service->markStatus('ring', 'connected'); + $this->assertNotNull(Addon::where('key', 'ring')->first()->connected_at); + } +}