diff --git a/cortex/auth_middleware.py b/cortex/auth_middleware.py index 2f3caf0..ec813a4 100644 --- a/cortex/auth_middleware.py +++ b/cortex/auth_middleware.py @@ -17,7 +17,7 @@ from starlette.responses import RedirectResponse, JSONResponse from auth_utils import COOKIE_NAME, decode_token # Paths that don't require a session cookie -_PUBLIC = {"/login", "/logout", "/health"} +_PUBLIC = {"/login", "/logout", "/health", "/manifest.json", "/sw.js", "/favicon.ico"} # Path prefixes that are always public (setup flow + webhooks + Google OAuth) _PUBLIC_PREFIXES = ("/setup/", "/channels/", "/webhook/", "/auth/google") diff --git a/cortex/routers/ui.py b/cortex/routers/ui.py index 614b9a2..3db0663 100644 --- a/cortex/routers/ui.py +++ b/cortex/routers/ui.py @@ -90,6 +90,18 @@ async def favicon(): return Response(content=_FAVICON_SVG, media_type="image/svg+xml") +@router.get("/sw.js", include_in_schema=False) +async def service_worker(): + from fastapi.responses import FileResponse + return FileResponse(str(_STATIC / "sw.js"), media_type="application/javascript") + + +@router.get("/manifest.json", include_in_schema=False) +async def web_manifest(): + from fastapi.responses import FileResponse + return FileResponse(str(_STATIC / "manifest.json"), media_type="application/manifest+json") + + # --------------------------------------------------------------------------- # Root redirect # --------------------------------------------------------------------------- diff --git a/cortex/static/app.js b/cortex/static/app.js index a74ca2a..5fedb18 100644 --- a/cortex/static/app.js +++ b/cortex/static/app.js @@ -1549,10 +1549,14 @@ // ── Theme toggle ────────────────────────────────────────────── const themeBtn = document.getElementById('theme-btn'); + const _metaThemeColor = document.getElementById('meta-theme-color'); + const _themeColors = { dark: '#1a1228', light: '#f2eef9' }; + function applyTheme(theme) { document.documentElement.setAttribute('data-theme', theme); themeBtn.textContent = theme === 'dark' ? '☀' : '☾'; themeBtn.title = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'; + if (_metaThemeColor) _metaThemeColor.content = _themeColors[theme] || _themeColors.dark; } { @@ -1729,3 +1733,8 @@ const stored = get_stored_session(); if (stored) resumeSession(stored, true).catch(clear_stored_session); } + + // ── Service worker registration ─────────────────────────────── + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').catch(() => {}); + } diff --git a/cortex/static/icon-192.png b/cortex/static/icon-192.png new file mode 100644 index 0000000..19fc079 Binary files /dev/null and b/cortex/static/icon-192.png differ diff --git a/cortex/static/icon-512.png b/cortex/static/icon-512.png new file mode 100644 index 0000000..b65642d Binary files /dev/null and b/cortex/static/icon-512.png differ diff --git a/cortex/static/icon.svg b/cortex/static/icon.svg new file mode 100644 index 0000000..2b8af3c --- /dev/null +++ b/cortex/static/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cortex/static/index.html b/cortex/static/index.html index cbd55c3..7ea35a9 100644 --- a/cortex/static/index.html +++ b/cortex/static/index.html @@ -5,6 +5,13 @@ Cortex — Inara + + + + + + + diff --git a/cortex/static/manifest.json b/cortex/static/manifest.json new file mode 100644 index 0000000..c039c64 --- /dev/null +++ b/cortex/static/manifest.json @@ -0,0 +1,30 @@ +{ + "name": "Cortex · Inara", + "short_name": "Cortex", + "description": "Personal AI assistant", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#1a1228", + "theme_color": "#1a1228", + "icons": [ + { + "src": "/static/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + } + ] +} diff --git a/cortex/static/sw.js b/cortex/static/sw.js new file mode 100644 index 0000000..6bba86e --- /dev/null +++ b/cortex/static/sw.js @@ -0,0 +1,75 @@ +const CACHE = 'cortex-v1'; + +const PRECACHE = [ + '/static/style.css', + '/static/app.js', + '/static/marked.min.js', + '/static/icon-192.png', + '/static/icon-512.png', + '/static/icon.svg', + '/static/manifest.json', +]; + +self.addEventListener('install', evt => { + evt.waitUntil( + caches.open(CACHE) + .then(c => c.addAll(PRECACHE)) + .then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', evt => { + evt.waitUntil( + caches.keys() + .then(keys => Promise.all( + keys.filter(k => k !== CACHE).map(k => caches.delete(k)) + )) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', evt => { + const url = new URL(evt.request.url); + + // Only handle same-origin GETs + if (evt.request.method !== 'GET' || url.origin !== self.location.origin) return; + + // Never intercept streaming or API calls + if ( + url.pathname.startsWith('/chat') || + url.pathname.startsWith('/orchestrate') || + url.pathname.startsWith('/api/') || + url.pathname.startsWith('/distill') || + url.pathname.startsWith('/webhook') || + url.pathname.startsWith('/auth/') + ) return; + + // Static assets — cache first, refresh in background (stale-while-revalidate) + if (url.pathname.startsWith('/static/')) { + evt.respondWith( + caches.open(CACHE).then(cache => + cache.match(evt.request).then(cached => { + const network = fetch(evt.request).then(resp => { + if (resp.ok) cache.put(evt.request, resp.clone()); + return resp; + }); + return cached || network; + }) + ) + ); + return; + } + + // HTML pages — network first, cached shell fallback + evt.respondWith( + fetch(evt.request) + .then(resp => { + if (resp.ok) { + const clone = resp.clone(); + caches.open(CACHE).then(c => c.put(evt.request, clone)); + } + return resp; + }) + .catch(() => caches.match(evt.request)) + ); +});