From 7b9ac0fe967632d41ada3bd886b7269599287b9c Mon Sep 17 00:00:00 2001 From: oxedom Date: Mon, 2 Feb 2026 11:28:38 +0200 Subject: [PATCH] pwa --- packages/frontend/index.html | 10 +- packages/frontend/public/manifest.json | 55 +++++++ packages/frontend/public/service-worker.js | 168 +++++++++++++++++++++ packages/frontend/src/main.tsx | 26 ++++ packages/frontend/vite.config.ts | 5 + 5 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 packages/frontend/public/manifest.json create mode 100644 packages/frontend/public/service-worker.js diff --git a/packages/frontend/index.html b/packages/frontend/index.html index 1b7ecb6..fb0ab8d 100644 --- a/packages/frontend/index.html +++ b/packages/frontend/index.html @@ -2,11 +2,19 @@ + + + + - + + + + + diff --git a/packages/frontend/public/manifest.json b/packages/frontend/public/manifest.json new file mode 100644 index 0000000..75dd3a0 --- /dev/null +++ b/packages/frontend/public/manifest.json @@ -0,0 +1,55 @@ +{ + "name": "Haflow", + "short_name": "Haflow", + "description": "Local-first orchestrator for AI-assisted missions", + "start_url": "/", + "scope": "/", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/favicon-16x16.png", + "sizes": "16x16", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png", + "purpose": "any maskable" + } + ], + "screenshots": [ + { + "src": "/favicon_image.jpg", + "sizes": "512x512", + "type": "image/jpeg", + "form_factor": "wide" + } + ], + "categories": ["productivity"], + "shortcuts": [ + { + "name": "New Mission", + "short_name": "New Mission", + "description": "Create a new mission", + "url": "/?new-mission", + "icons": [ + { + "src": "/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png" + } + ] + } + ] +} diff --git a/packages/frontend/public/service-worker.js b/packages/frontend/public/service-worker.js new file mode 100644 index 0000000..416a70b --- /dev/null +++ b/packages/frontend/public/service-worker.js @@ -0,0 +1,168 @@ +const CACHE_NAME = 'haflow-v1'; +const RUNTIME_CACHE = 'haflow-runtime-v1'; +const API_CACHE = 'haflow-api-v1'; + +// Files to cache on install +const ASSETS_TO_CACHE = [ + '/', + '/index.html', + '/manifest.json', + '/favicon.ico', + '/favicon-16x16.png', + '/favicon-32x32.png', + '/apple-touch-icon.png' +]; + +// Install event - cache app shell +self.addEventListener('install', (event) => { + console.log('[ServiceWorker] Install'); + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + console.log('[ServiceWorker] Caching app shell'); + return cache.addAll(ASSETS_TO_CACHE); + }).catch((error) => { + console.warn('[ServiceWorker] Cache addAll failed:', error); + // Continue even if some assets fail to cache + return caches.open(CACHE_NAME).then((cache) => { + return Promise.all( + ASSETS_TO_CACHE.map((url) => + cache.add(url).catch(() => { + console.warn('[ServiceWorker] Failed to cache:', url); + }) + ) + ); + }); + }) + ); + self.skipWaiting(); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + console.log('[ServiceWorker] Activate'); + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames.map((cacheName) => { + if (cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE && cacheName !== API_CACHE) { + console.log('[ServiceWorker] Deleting cache:', cacheName); + return caches.delete(cacheName); + } + }) + ); + }) + ); + self.clients.claim(); +}); + +// Fetch event - implement caching strategies +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + // Skip non-GET requests + if (request.method !== 'GET') { + return; + } + + // Skip external URLs + if (url.origin !== self.location.origin) { + return; + } + + // API requests - network first, fall back to cache + if (url.pathname.startsWith('/api/')) { + event.respondWith(networkFirstStrategy(request, API_CACHE)); + return; + } + + // Static assets (JS, CSS, fonts) - cache first + if (isStaticAsset(url.pathname)) { + event.respondWith(cacheFirstStrategy(request, RUNTIME_CACHE)); + return; + } + + // HTML and other documents - network first, fall back to cache + event.respondWith(networkFirstStrategy(request, RUNTIME_CACHE)); +}); + +/** + * Cache first strategy: try cache, fall back to network + */ +function cacheFirstStrategy(request, cacheName) { + return caches.match(request).then((response) => { + if (response) { + return response; + } + return fetch(request) + .then((response) => { + if (!response || response.status !== 200 || response.type === 'error') { + return response; + } + const responseToCache = response.clone(); + caches.open(cacheName).then((cache) => { + cache.put(request, responseToCache); + }); + return response; + }) + .catch(() => { + console.warn('[ServiceWorker] Fetch failed:', request.url); + // Return offline page or error response + return new Response('Offline - resource not available', { + status: 503, + statusText: 'Service Unavailable', + headers: new Headers({ + 'Content-Type': 'text/plain' + }) + }); + }); + }); +} + +/** + * Network first strategy: try network, fall back to cache + */ +function networkFirstStrategy(request, cacheName) { + return fetch(request) + .then((response) => { + if (!response || response.status !== 200 || response.type === 'error') { + return response; + } + const responseToCache = response.clone(); + caches.open(cacheName).then((cache) => { + cache.put(request, responseToCache); + }); + return response; + }) + .catch(() => { + console.warn('[ServiceWorker] Network failed, trying cache:', request.url); + return caches.match(request).then((response) => { + if (response) { + return response; + } + return new Response('Offline - resource not available', { + status: 503, + statusText: 'Service Unavailable', + headers: new Headers({ + 'Content-Type': 'text/plain' + }) + }); + }); + }); +} + +/** + * Check if URL is a static asset + */ +function isStaticAsset(pathname) { + return /\.(js|css|woff|woff2|ttf|eot|svg|png|jpg|jpeg|gif|ico|webp)$/i.test(pathname) || + pathname.includes('/assets/'); +} + +// Handle messages from clients +self.addEventListener('message', (event) => { + console.log('[ServiceWorker] Message received:', event.data); + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); diff --git a/packages/frontend/src/main.tsx b/packages/frontend/src/main.tsx index bef5202..23f879e 100644 --- a/packages/frontend/src/main.tsx +++ b/packages/frontend/src/main.tsx @@ -3,6 +3,32 @@ import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +// Register service worker for PWA support +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/service-worker.js').then((registration) => { + console.log('[App] Service Worker registered:', registration); + + // Listen for updates + registration.addEventListener('updatefound', () => { + const newWorker = registration.installing; + if (newWorker) { + newWorker.addEventListener('statechange', () => { + if (newWorker.state === 'activated') { + // Notify user of update - could trigger UI notification here + console.log('[App] Service Worker updated, refresh to use new version'); + // Optionally reload the page + // window.location.reload(); + } + }); + } + }); + }).catch((error) => { + console.warn('[App] Service Worker registration failed:', error); + }); + }); +} + createRoot(document.getElementById('root')!).render( diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index 23be1c1..7f951b8 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -16,4 +16,9 @@ export default defineConfig({ '@': path.resolve(__dirname, './src'), }, }, + server: { + headers: { + 'Service-Worker-Allowed': '/', + }, + }, })