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
parent
3ed49ee882
commit
8aa3257fa2
|
|
@ -36,6 +36,19 @@ server {
|
||||||
try_files $uri $uri/ /index.php?$query_string;
|
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 = /favicon.ico { access_log off; log_not_found off; }
|
||||||
location = /robots.txt { access_log off; log_not_found off; }
|
location = /robots.txt { access_log off; log_not_found off; }
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
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 |
|
|
@ -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" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -85,3 +85,11 @@ window.Echo = new Echo({
|
||||||
forceTLS: isHttps,
|
forceTLS: isHttps,
|
||||||
enabledTransports: ['ws', 'wss'],
|
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(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,17 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
|
<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>
|
<title>{{ $title ?? __('common.app_name') }} · {{ __('common.app_name') }}</title>
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
@livewireStyles
|
@livewireStyles
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
|
<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>
|
<title>{{ __('auth.title') }} · {{ __('common.app_name') }}</title>
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
@livewireStyles
|
@livewireStyles
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue