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>
feat/phase-1-bootstrap
HomeOS Bootstrap 2026-07-18 01:25:24 +02:00
parent 3ed49ee882
commit 8aa3257fa2
12 changed files with 217 additions and 0 deletions

View File

@ -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; }

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -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" }]
}
]
}

50
public/offline.html Normal file
View File

@ -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>

74
public/sw.js Normal file
View File

@ -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();
}
}

View File

@ -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(() => {});
});
}

View File

@ -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

View File

@ -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