diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf
index 8739d1a..7501cf3 100644
--- a/docker/nginx/default.conf
+++ b/docker/nginx/default.conf
@@ -36,6 +36,19 @@ server {
try_files $uri $uri/ /index.php?$query_string;
}
+ # PWA: correct manifest MIME, and keep the service worker un-cached so updates ship.
+ location = /manifest.webmanifest {
+ types { }
+ default_type application/manifest+json;
+ add_header Cache-Control "public, max-age=3600";
+ access_log off;
+ }
+
+ location = /sw.js {
+ add_header Cache-Control "no-cache";
+ add_header Service-Worker-Allowed "/";
+ }
+
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 0000000..5fcda22
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
new file mode 100644
index 0000000..1c1adbf
Binary files /dev/null and b/public/icons/icon-192.png differ
diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png
new file mode 100644
index 0000000..5b39a49
Binary files /dev/null and b/public/icons/icon-512.png differ
diff --git a/public/icons/icon-maskable-192.png b/public/icons/icon-maskable-192.png
new file mode 100644
index 0000000..e2e49ec
Binary files /dev/null and b/public/icons/icon-maskable-192.png differ
diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png
new file mode 100644
index 0000000..613fe07
Binary files /dev/null and b/public/icons/icon-maskable-512.png differ
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..6e99d59
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,56 @@
+{
+ "name": "HomeOS",
+ "short_name": "HomeOS",
+ "description": "Selbst-gehostete Haussteuerung — Räume, Geräte, Automationen.",
+ "id": "/",
+ "start_url": "/dashboard",
+ "scope": "/",
+ "display": "standalone",
+ "display_override": ["standalone", "minimal-ui"],
+ "orientation": "any",
+ "background_color": "#080D18",
+ "theme_color": "#0D1424",
+ "lang": "de",
+ "dir": "ltr",
+ "categories": ["utilities", "lifestyle"],
+ "icons": [
+ {
+ "src": "/icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/icons/icon-maskable-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "/icons/icon-maskable-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ],
+ "shortcuts": [
+ {
+ "name": "Steuerung",
+ "short_name": "Steuerung",
+ "url": "/panel",
+ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
+ },
+ {
+ "name": "Räume",
+ "short_name": "Räume",
+ "url": "/rooms",
+ "icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192" }]
+ }
+ ]
+}
diff --git a/public/offline.html b/public/offline.html
new file mode 100644
index 0000000..02b70aa
--- /dev/null
+++ b/public/offline.html
@@ -0,0 +1,50 @@
+
+
+
+
+
+ Offline · HomeOS
+
+
+
+
+
+
+
+
Keine Verbindung
+
HomeOS ist gerade nicht erreichbar. Prüfe deine Netzwerkverbindung zum Server.
+
+
+
+
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..edc756a
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,74 @@
+/**
+ * HomeOS service worker. Deliberately conservative for an authenticated Livewire app:
+ * - never intercepts non-GET (login, Livewire updates, broadcasting/auth stay online-only)
+ * - cache-first only for fingerprinted, immutable assets under /build/ and static icons
+ * - navigations are network-first with an offline fallback, so authenticated HTML is never
+ * served stale from cache
+ * Bump VERSION to invalidate old caches on the next activate.
+ */
+const VERSION = 'homeos-v1';
+const STATIC_CACHE = `${VERSION}-static`;
+const OFFLINE_URL = '/offline.html';
+const PRECACHE = [OFFLINE_URL, '/icons/icon-192.png', '/icons/icon-512.png'];
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ (async () => {
+ const cache = await caches.open(STATIC_CACHE);
+ await cache.addAll(PRECACHE);
+ await self.skipWaiting();
+ })(),
+ );
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ (async () => {
+ const keys = await caches.keys();
+ await Promise.all(keys.filter((k) => !k.startsWith(VERSION)).map((k) => caches.delete(k)));
+ await self.clients.claim();
+ })(),
+ );
+});
+
+self.addEventListener('message', (event) => {
+ if (event.data === 'skipWaiting') self.skipWaiting();
+});
+
+self.addEventListener('fetch', (event) => {
+ const req = event.request;
+ if (req.method !== 'GET') return; // login / Livewire / broadcasting are POST — stay online
+
+ const url = new URL(req.url);
+ if (url.origin !== self.location.origin) return; // don't touch cross-origin
+
+ // Immutable, fingerprinted build output + static icons → cache-first.
+ if (url.pathname.startsWith('/build/') || url.pathname.startsWith('/icons/')) {
+ event.respondWith(cacheFirst(req));
+ return;
+ }
+
+ // Page loads → network-first, fall back to the offline shell only when truly offline.
+ if (req.mode === 'navigate') {
+ event.respondWith(networkFirst(req));
+ }
+});
+
+async function cacheFirst(req) {
+ const cache = await caches.open(STATIC_CACHE);
+ const hit = await cache.match(req);
+ if (hit) return hit;
+ const res = await fetch(req);
+ if (res && res.ok) cache.put(req, res.clone());
+ return res;
+}
+
+async function networkFirst(req) {
+ try {
+ return await fetch(req);
+ } catch (e) {
+ const cache = await caches.open(STATIC_CACHE);
+ const offline = await cache.match(OFFLINE_URL);
+ return offline || Response.error();
+ }
+}
diff --git a/resources/js/app.js b/resources/js/app.js
index 8337e44..f02bd0f 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -85,3 +85,11 @@ window.Echo = new Echo({
forceTLS: isHttps,
enabledTransports: ['ws', 'wss'],
});
+
+// Register the PWA service worker so HomeOS installs on tablets/phones and survives
+// brief server blips (offline shell). sw.js lives at the web root for a "/" scope.
+if ('serviceWorker' in navigator) {
+ window.addEventListener('load', () => {
+ navigator.serviceWorker.register('/sw.js').catch(() => {});
+ });
+}
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php
index d0c3329..bab9047 100644
--- a/resources/views/layouts/app.blade.php
+++ b/resources/views/layouts/app.blade.php
@@ -5,6 +5,17 @@
+
+
+ {{-- PWA: installable on tablet/phone (§ tablet control) --}}
+
+
+
+
+
+
+
+
{{ $title ?? __('common.app_name') }} · {{ __('common.app_name') }}
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
diff --git a/resources/views/layouts/guest.blade.php b/resources/views/layouts/guest.blade.php
index 0f1251f..e7b80df 100644
--- a/resources/views/layouts/guest.blade.php
+++ b/resources/views/layouts/guest.blade.php
@@ -5,6 +5,11 @@
+
+
+
+
+
{{ __('auth.title') }} · {{ __('common.app_name') }}
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles