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:
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
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
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
4
cortex/static/icon.svg
Normal 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 |
@@ -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">
|
||||||
|
|||||||
30
cortex/static/manifest.json
Normal file
30
cortex/static/manifest.json
Normal 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
75
cortex/static/sw.js
Normal 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))
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user