feat: PWA support — manifest, service worker, icons, public auth exemption

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-29 18:46:33 -04:00
parent f726d78979
commit 25182a1765
9 changed files with 138 additions and 1 deletions

View File

@@ -17,7 +17,7 @@ from starlette.responses import RedirectResponse, JSONResponse
from auth_utils import COOKIE_NAME, decode_token from auth_utils import COOKIE_NAME, decode_token
# Paths that don't require a session cookie # 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) # Path prefixes that are always public (setup flow + webhooks + Google OAuth)
_PUBLIC_PREFIXES = ("/setup/", "/channels/", "/webhook/", "/auth/google") _PUBLIC_PREFIXES = ("/setup/", "/channels/", "/webhook/", "/auth/google")

View File

@@ -90,6 +90,18 @@ async def favicon():
return Response(content=_FAVICON_SVG, media_type="image/svg+xml") 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 # Root redirect
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -1549,10 +1549,14 @@
// ── Theme toggle ────────────────────────────────────────────── // ── Theme toggle ──────────────────────────────────────────────
const themeBtn = document.getElementById('theme-btn'); const themeBtn = document.getElementById('theme-btn');
const _metaThemeColor = document.getElementById('meta-theme-color');
const _themeColors = { dark: '#1a1228', light: '#f2eef9' };
function applyTheme(theme) { function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
themeBtn.textContent = theme === 'dark' ? '☀' : '☾'; themeBtn.textContent = theme === 'dark' ? '☀' : '☾';
themeBtn.title = theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'; 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(); const stored = get_stored_session();
if (stored) resumeSession(stored, true).catch(clear_stored_session); if (stored) resumeSession(stored, true).catch(clear_stored_session);
} }
// ── Service worker registration ───────────────────────────────
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}

BIN
cortex/static/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
cortex/static/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

4
cortex/static/icon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#1a1228"/>
<text x="256" y="390" font-size="340" text-anchor="middle" font-family="system-ui, -apple-system, sans-serif"></text>
</svg>

After

Width:  |  Height:  |  Size: 251 B

View File

@@ -5,6 +5,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cortex — Inara</title> <title>Cortex — Inara</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>✨</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>✨</text></svg>">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#1a1228" id="meta-theme-color">
<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="Cortex">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap" rel="stylesheet">

View File

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

75
cortex/static/sw.js Normal file
View File

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