feat(addons): installable integrations framework + Ring connect UI

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 <noreply@anthropic.com>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 01:47:02 +02:00
parent 1a3556be2c
commit 52c1c5d3ac
19 changed files with 1342 additions and 0 deletions

753
.reviews/r15-8aa3257.md Normal file
View File

@ -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 <homeos@localhost>
AuthorDate: Sat Jul 18 01:25:24 2026 +0200
Commit: HomeOS Bootstrap <homeos@localhost>
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 <noreply@anthropic.com>
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 @@
+<!doctype html>
+<html lang="de">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Offline · HomeOS</title>
+ <style>
+ :root { color-scheme: dark; }
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ body {
+ min-height: 100dvh;
+ display: grid;
+ place-items: center;
+ background: #080D18;
+ color: #EAF0FB;
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+ padding: 24px;
+ text-align: center;
+ }
+ .card { max-width: 340px; display: flex; flex-direction: column; align-items: center; gap: 18px; }
+ .badge {
+ width: 72px; height: 72px; border-radius: 20px; background: #0D1424;
+ display: grid; place-items: center;
+ }
+ h1 { font-size: 19px; font-weight: 700; letter-spacing: -0.01em; }
+ p { font-size: 14px; line-height: 1.5; color: #93A1BD; }
+ button {
+ margin-top: 4px; border: 0; cursor: pointer;
+ background: #4FC1FF; color: #080D18; font-weight: 700; font-size: 13.5px;
+ padding: 10px 18px; border-radius: 10px;
+ }
+ button:hover { filter: brightness(1.08); }
+ </style>
+</head>
+<body>
+ <div class="card">
+ <div class="badge">
+ <svg width="40" height="40" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
+ <rect x="6" y="6" width="9" height="12" rx="2" fill="#4FC1FF"/>
+ <rect x="18" y="6" width="8" height="6" rx="2" fill="#4FC1FF" opacity="0.75"/>
+ <rect x="18" y="15" width="8" height="11" rx="2" fill="#4FC1FF"/>
+ <rect x="6" y="21" width="9" height="5" rx="2" fill="#4FC1FF" opacity="0.75"/>
+ </svg>
+ </div>
+ <h1>Keine Verbindung</h1>
+ <p>HomeOS ist gerade nicht erreichbar. Prüfe deine Netzwerkverbindung zum Server.</p>
+ <button onclick="location.reload()">Erneut versuchen</button>
+ </div>
+</body>
+</html>
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 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
+ <link rel="icon" href="{{ asset('favicon.ico') }}" sizes="any">
+
+ {{-- PWA: installable on tablet/phone (§ tablet control) --}}
+ <link rel="manifest" href="{{ asset('manifest.webmanifest') }}">
+ <meta name="theme-color" content="#0D1424">
+ <meta name="mobile-web-app-capable" content="yes">
+ <meta name="apple-mobile-web-app-capable" content="yes">
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
+ <meta name="apple-mobile-web-app-title" content="HomeOS">
+ <link rel="apple-touch-icon" href="{{ asset('apple-touch-icon.png') }}">
+
<title>{{ $title ?? __('common.app_name') }} · {{ __('common.app_name') }}</title>
@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 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
+ <link rel="manifest" href="{{ asset('manifest.webmanifest') }}">
+ <meta name="theme-color" content="#0D1424">
+ <meta name="apple-mobile-web-app-capable" content="yes">
+ <meta name="apple-mobile-web-app-title" content="HomeOS">
+ <link rel="apple-touch-icon" href="{{ asset('apple-touch-icon.png') }}">
<title>{{ __('auth.title') }} · {{ __('common.app_name') }}</title>
@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/<key>,
# Laravel publishes to /apps/<id>/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()`.

View File

@ -0,0 +1,37 @@
<?php
namespace App\Livewire\Addons;
use App\Services\AddonRegistry;
use App\Services\AddonService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
/** Key awaiting an uninstall confirmation — set by the card before the confirm modal opens. */
public ?string $pendingUninstall = null;
public function install(string $key): void
{
app(AddonService::class)->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(),
]);
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Addon;
use App\Services\AddonService;
use LivewireUI\Modal\ModalComponent;
/**
* Captures the Ring account login. We never call Ring ourselves (no local API; the ring-mqtt
* sidecar owns the OAuth + 2FA exchange, per the handoff) the entered credentials are stored
* encrypted and handed to that backend, which completes the connection and reports over MQTT.
*/
class RingConnect extends ModalComponent
{
public string $email = '';
public string $password = '';
public string $code = '';
public bool $hasStoredPassword = false;
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(): void
{
$addon = Addon::where('key', 'ring')->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');
}
}

23
app/Models/Addon.php Normal file
View File

@ -0,0 +1,23 @@
<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use Illuminate\Database\Eloquent\Model;
/**
* An installable integration (Ring, later others). The registry (AddonRegistry) holds the static
* definition; this row holds install state + encrypted config (e.g. the Ring refresh token).
*/
class Addon extends Model
{
use HasUuid;
protected $fillable = ['uuid', 'key', 'installed', 'status', 'status_detail', 'config', 'connected_at'];
protected $casts = [
'installed' => 'boolean',
'config' => 'encrypted:array', // secrets never stored in plaintext
'connected_at' => 'datetime',
];
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Services;
use App\Models\Addon;
/**
* Static catalogue of installable integrations. Each definition is UI-only metadata; runtime
* state (installed?, connected?, secrets) lives on the {@see Addon} row. Adding an integration
* later is a new entry here plus its normalizer under Support/Mqtt (H3).
*/
class AddonRegistry
{
/** @return array<string, array<string, mixed>> */
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<int, array<string, mixed>>
*/
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();
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Services;
use App\Models\Addon;
use Illuminate\Support\Facades\DB;
/**
* Install/uninstall integrations and reflect their backend connection status. Addons are opt-in:
* installing one only records intent + config here the actual bridge (e.g. the ring-mqtt
* sidecar) runs as its own container and reports health over MQTT.
*/
class AddonService
{
public function row(string $key): Addon
{
return Addon::firstOrCreate(['key' => $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<string, mixed> $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();
});
}
}

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('addons', function (Blueprint $table) {
$table->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');
}
};

32
lang/de/addons.php Normal file
View File

@ -0,0 +1,32 @@
<?php
return [
'subtitle' => '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',
],
];

View File

@ -15,4 +15,5 @@ return [
'menu' => 'Menü', 'menu' => 'Menü',
'close' => 'Schließen', 'close' => 'Schließen',
'cancel' => 'Abbrechen', 'cancel' => 'Abbrechen',
'docs' => 'Doku',
]; ];

View File

@ -17,6 +17,7 @@ return [
'access' => 'Zugang & Face-ID', 'access' => 'Zugang & Face-ID',
'network' => 'Netzwerk & Discovery', 'network' => 'Netzwerk & Discovery',
'automations' => 'Automationen', 'automations' => 'Automationen',
'addons' => 'Add-ons',
'host' => 'Host & Dienste', 'host' => 'Host & Dienste',
'settings' => 'Einstellungen', 'settings' => 'Einstellungen',

32
lang/en/addons.php Normal file
View File

@ -0,0 +1,32 @@
<?php
return [
'subtitle' => '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',
],
];

View File

@ -15,4 +15,5 @@ return [
'menu' => 'Menu', 'menu' => 'Menu',
'close' => 'Close', 'close' => 'Close',
'cancel' => 'Cancel', 'cancel' => 'Cancel',
'docs' => 'Docs',
]; ];

View File

@ -17,6 +17,7 @@ return [
'access' => 'Access & Face ID', 'access' => 'Access & Face ID',
'network' => 'Network & Discovery', 'network' => 'Network & Discovery',
'automations' => 'Automations', 'automations' => 'Automations',
'addons' => 'Add-ons',
'host' => 'Host & Services', 'host' => 'Host & Services',
'settings' => 'Settings', 'settings' => 'Settings',

View File

@ -24,6 +24,9 @@
'user' => '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-6 8-6s8 2 8 6"/>', 'user' => '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-6 8-6s8 2 8 6"/>',
'chevron' => '<path d="m9 18 6-6-6-6"/>', 'chevron' => '<path d="m9 18 6-6-6-6"/>',
'bolt' => '<path d="M13 3 5 13h6l-1 8 8-10h-6l1-8z"/>', 'bolt' => '<path d="M13 3 5 13h6l-1 8 8-10h-6l1-8z"/>',
'package' => '<path d="M16.5 9.4 7.5 4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
'doorbell' => '<rect x="7" y="2" width="10" height="20" rx="3"/><circle cx="12" cy="9" r="2.2"/><path d="M10.5 15.5h3"/>',
'cloud' => '<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/>',
'dot' => '<circle cx="12" cy="12" r="4"/>', 'dot' => '<circle cx="12" cy="12" r="4"/>',
]; ];
$inner = $paths[$name] ?? $paths['dot']; $inner = $paths[$name] ?? $paths['dot'];

View File

@ -16,6 +16,7 @@
'nav.section_system' => [ 'nav.section_system' => [
['key' => 'network', 'icon' => 'network', 'route' => 'network', 'lock' => false], ['key' => 'network', 'icon' => 'network', 'route' => 'network', 'lock' => false],
['key' => 'automations', 'icon' => 'automation', 'route' => 'automations.index', 'active' => 'automations.*', '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' => 'host', 'icon' => 'activity', 'route' => 'host', 'lock' => false],
['key' => 'settings', 'icon' => 'settings', 'route' => 'settings', 'lock' => false], ['key' => 'settings', 'icon' => 'settings', 'route' => 'settings', 'lock' => false],
], ],

View File

@ -0,0 +1,68 @@
<div>
<x-topbar :title="__('nav.addons')" :subtitle="__('addons.subtitle')" />
<div class="px-5 lg:px-[26px] py-6 flex flex-col gap-5 max-w-[1560px] mx-auto w-full">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
@foreach ($addons as $addon)
@php
$statusState = match ($addon['status']) {
'connected' => 'online',
'connecting' => 'warning',
'error' => 'offline',
default => 'neutral',
};
@endphp
<x-panel>
<div class="flex flex-col gap-4">
<div class="flex items-start gap-3.5">
<span class="grid place-items-center w-11 h-11 rounded-xl bg-accent/10 text-accent shrink-0"><x-icon :name="$addon['icon']" :size="22" /></span>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="text-[15px] font-bold text-ink">{{ $addon['name'] }}</h3>
@if ($addon['cloud'])
<span title="{{ __('addons.cloud_hint') }}" class="inline-flex items-center gap-1 rounded-full bg-inset px-2 py-[2px] text-[10px] font-bold text-ink-2">
<x-icon name="cloud" :size="12" /> {{ __('addons.cloud') }}
</span>
@endif
</div>
<p class="text-[12.5px] text-ink-2 mt-0.5">{{ __($addon['summary_key']) }}</p>
</div>
@if ($addon['installed'])
<x-status-pill :state="$statusState" class="shrink-0">{{ __('addons.status_'.$addon['status']) }}</x-status-pill>
@endif
</div>
<p class="text-[12.5px] text-ink-3 leading-relaxed">{{ __($addon['body_key']) }}</p>
@if ($addon['installed'] && $addon['status_detail'])
<p class="text-[11.5px] text-offline font-mono">{{ $addon['status_detail'] }}</p>
@endif
<div class="flex items-center gap-2 pt-0.5">
@if (! $addon['installed'])
<button type="button" wire:click="install('{{ $addon['key'] }}')"
class="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3.5 py-2 text-[13px] font-bold text-base hover:brightness-110 transition-[filter]">
<x-icon name="plus" :size="15" /> {{ __('addons.install') }}
</button>
@else
<button type="button" wire:click="$dispatch('openModal', { component: 'modals.ring-connect' })"
class="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3.5 py-2 text-[13px] font-bold text-base hover:brightness-110 transition-[filter]">
<x-icon name="user" :size="15" /> {{ $addon['status'] === 'connected' ? __('addons.reconnect') : __('addons.connect') }}
</button>
<a href="{{ $addon['docs'] }}" target="_blank" rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 rounded-lg border border-line px-3 py-2 text-[12.5px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">
{{ __('common.docs') }}
</a>
<button type="button"
x-on:click="$wire.set('pendingUninstall', @js($addon['key'])); $wire.$dispatch('openModal', { component: 'modals.confirm', arguments: { title: @js(__('addons.uninstall_title', ['name' => $addon['name']])), body: @js(__('addons.uninstall_body')), confirmLabel: @js(__('addons.uninstall')), event: 'addon-uninstall', to: 'addons', danger: true } })"
class="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-line px-3 py-2 text-[12.5px] font-semibold text-ink-3 hover:text-offline hover:border-offline/40 transition-colors">
{{ __('addons.uninstall') }}
</button>
@endif
</div>
</div>
</x-panel>
@endforeach
</div>
</div>
</div>

View File

@ -0,0 +1,45 @@
<div class="flex flex-col">
<header class="flex items-center gap-3 px-5 py-4 border-b border-line-soft">
<span class="grid place-items-center w-9 h-9 rounded-lg bg-accent/10 text-accent shrink-0"><x-icon name="doorbell" :size="18" /></span>
<h2 class="text-[15px] font-bold text-ink">{{ __('addons.ring.connect_title') }}</h2>
<button type="button" wire:click="closeModal" class="ml-auto grid place-items-center w-9 h-9 rounded-lg text-ink-3 hover:bg-raised hover:text-ink transition-colors" aria-label="{{ __('common.close') }}">
<x-icon name="close" :size="18" />
</button>
</header>
<form wire:submit="save" class="p-5 flex flex-col gap-4">
<p class="text-[12px] text-ink-3 leading-relaxed flex items-start gap-2">
<x-icon name="cloud" :size="15" class="mt-0.5 shrink-0" />
<span>{{ __('addons.ring.backend_note') }}</span>
</p>
<div class="flex flex-col gap-1.5">
<label for="ring-email" class="text-[12px] font-semibold text-ink-2">{{ __('addons.ring.email') }}</label>
<input wire:model="email" id="ring-email" type="email" autocomplete="off" autofocus
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@error('email') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
<div class="flex flex-col gap-1.5">
<label for="ring-password" class="text-[12px] font-semibold text-ink-2">{{ __('addons.ring.password') }}</label>
<input wire:model="password" id="ring-password" type="password" autocomplete="new-password"
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@if ($hasStoredPassword)
<p class="text-[11.5px] text-ink-3">{{ __('addons.ring.password_stored') }}</p>
@endif
@error('password') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
<div class="flex flex-col gap-1.5">
<label for="ring-code" class="text-[12px] font-semibold text-ink-2">{{ __('addons.ring.code') }}</label>
<input wire:model="code" id="ring-code" type="text" inputmode="numeric" autocomplete="one-time-code"
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm font-mono text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
@error('code') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
</div>
<div class="flex items-center justify-end gap-2 pt-1">
<button type="button" wire:click="closeModal" class="rounded-lg border border-line px-3.5 py-2 text-[13px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">{{ __('common.cancel') }}</button>
<button type="submit" class="rounded-lg bg-accent px-3.5 py-2 text-[13px] font-bold text-base hover:brightness-110 transition-[filter]">{{ __('addons.ring.save') }}</button>
</div>
</form>
</div>

View File

@ -1,6 +1,7 @@
<?php <?php
use App\Livewire\Access; use App\Livewire\Access;
use App\Livewire\Addons\Index as AddonIndex;
use App\Livewire\Auth\Login; use App\Livewire\Auth\Login;
use App\Livewire\Automations\Index as AutomationIndex; use App\Livewire\Automations\Index as AutomationIndex;
use App\Livewire\Dashboard; use App\Livewire\Dashboard;
@ -35,6 +36,7 @@ Route::middleware('auth')->group(function () {
Route::get('/access', Access::class)->name('access'); Route::get('/access', Access::class)->name('access');
Route::get('/network', DiscoveryIndex::class)->name('network'); Route::get('/network', DiscoveryIndex::class)->name('network');
Route::get('/automations', AutomationIndex::class)->name('automations.index'); 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('/settings', SettingsIndex::class)->name('settings');
Route::get('/host', Host::class)->name('host'); Route::get('/host', Host::class)->name('host');

View File

@ -0,0 +1,93 @@
<?php
namespace Tests\Feature;
use App\Models\Addon;
use App\Services\AddonRegistry;
use App\Services\AddonService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class AddonTest extends TestCase
{
use RefreshDatabase;
public function test_registry_exposes_ring_with_state_defaults(): void
{
$addons = AddonRegistry::withState();
$this->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);
}
}