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
parent
1a3556be2c
commit
52c1c5d3ac
|
|
@ -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()`.
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
@ -15,4 +15,5 @@ return [
|
|||
'menu' => 'Menü',
|
||||
'close' => 'Schließen',
|
||||
'cancel' => 'Abbrechen',
|
||||
'docs' => 'Doku',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ return [
|
|||
'access' => 'Zugang & Face-ID',
|
||||
'network' => 'Netzwerk & Discovery',
|
||||
'automations' => 'Automationen',
|
||||
'addons' => 'Add-ons',
|
||||
'host' => 'Host & Dienste',
|
||||
'settings' => 'Einstellungen',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
];
|
||||
|
|
@ -15,4 +15,5 @@ return [
|
|||
'menu' => 'Menu',
|
||||
'close' => 'Close',
|
||||
'cancel' => 'Cancel',
|
||||
'docs' => 'Docs',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ return [
|
|||
'access' => 'Access & Face ID',
|
||||
'network' => 'Network & Discovery',
|
||||
'automations' => 'Automations',
|
||||
'addons' => 'Add-ons',
|
||||
'host' => 'Host & Services',
|
||||
'settings' => 'Settings',
|
||||
|
||||
|
|
|
|||
|
|
@ -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"/>',
|
||||
'chevron' => '<path d="m9 18 6-6-6-6"/>',
|
||||
'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"/>',
|
||||
];
|
||||
$inner = $paths[$name] ?? $paths['dot'];
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Access;
|
||||
use App\Livewire\Addons\Index as AddonIndex;
|
||||
use App\Livewire\Auth\Login;
|
||||
use App\Livewire\Automations\Index as AutomationIndex;
|
||||
use App\Livewire\Dashboard;
|
||||
|
|
@ -35,6 +36,7 @@ Route::middleware('auth')->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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue