From 25182a1765e91bc1db732aca97ad009f1dffddae Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Wed, 29 Apr 2026 18:46:33 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20PWA=20support=20=E2=80=94=20manifest,?= =?UTF-8?q?=20service=20worker,=20icons,=20public=20auth=20exemption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- cortex/auth_middleware.py | 2 +- cortex/routers/ui.py | 12 ++++++ cortex/static/app.js | 9 +++++ cortex/static/icon-192.png | Bin 0 -> 2270 bytes cortex/static/icon-512.png | Bin 0 -> 6826 bytes cortex/static/icon.svg | 4 ++ cortex/static/index.html | 7 ++++ cortex/static/manifest.json | 30 +++++++++++++++ cortex/static/sw.js | 75 ++++++++++++++++++++++++++++++++++++ 9 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 cortex/static/icon-192.png create mode 100644 cortex/static/icon-512.png create mode 100644 cortex/static/icon.svg create mode 100644 cortex/static/manifest.json create mode 100644 cortex/static/sw.js 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 0000000000000000000000000000000000000000..19fc079590ed44d0acaf24a5a1bd4397a71e28e7 GIT binary patch literal 2270 zcmeHJ_d6R37f#IF)Tqa*Ew$Q8iYhgl7}ttXuC!N)b$!(u6>6kvM9NpaYE@#!ED?_) zI*f{h>Oe1oXsIowsM$~=M)T<(aew=s^PKbkaDF)N^FHS}?@4pJVlNI>1Oos7aioKd z`!T-$l^~Jh>OOT_^cX|}oa}7?{9jSQs38IXAR^Mn>S}Ds`t)r0bc{+5XKZPEeH6Po z8dF1TF7}GR%Jk~Xd=;f-nv9YB#zXcEmgCVBiNZ^=Q|4Cbw@FhoEAaJe+x+32txm1# zd*ae7M?#avo1e>q2UH-RT`na4!Rn5)&^aG858kgUV&n?NV}pJS<-{R8eFQ#tB*sUX zG1yuD*mCkTApl+`D$lseLKwzZK9AWQ@9OF*^0KgU5&Zn(>p%Ia(d<<#HUwGP?5)HU zRw)P$Ck`C#egDknOu*2Bj1`}#rm(GeFk6&hJQm>M|688=QAB9q(SH(Giv89D>Le*rBW$vY29ic-n4qWlz0Z#9+_Y5mQz(8 zdcEc-cYXMNd(6mJAA^-(XUgWWSsBJB zXc91?!59lOQ~1_I(?u#3M%>shGY_v<_qCn)x0S^6E!wDWcimJ$J(Zzru@qx8H z_fKR57GW-9EBSebY9@WUuM3o{dsXE{EzYY#(&FeI3^g8zub~!-Z85_6L8gd<-j*1I z!6&-Du>pK*<%_xNfgi3fUl%<}{#HSDFCzj?6-r$R)cGDs=Q8?y6RN`K#Q5D~Ug;je zVI*7>N2SIWNTbn6i!-5S&{_@dbNr!l`l_~`lA6B(jqbl}$xPlj;#+8i>BWTiK|8e0V^btO#l~w=?Icyw2)SNcMt8oy%`QBX#^qD-?yftnZEJLc}-OU5$^glEkkk9a{GHQQ{#g@Hbs#)GZ;_%UKsDJ%1=$LY&R3l4+011ka@UC~7s!!A0CMh7`5J&cumTs4G>H^DAor zU?~I$-}ynf+&S~`+v!(!fQU@MoFeeGkpMna2rvc$P^AFg8i4;d{v}Clkx{miK$+7) z#*QRtoJ7S*O4^y=%4Ji0va=0x-qsZ;^R~OnImI>`D5F7FkhN}GHo#j8;)5)qg{23_>#t0d^AQh$T=ty0 zY?{t;HK_JgmZp`)P&xK4BP)tIC*}JCmzF%lT!y#KtT->Fco;acR zjvT7QbyWP2curldDhj#P_x`SCdbqO-!@To(8=Uwf&$~w;(*MQ7SrvHf#2_89rJG}a z4xMx^I0=ISnu1tKX1cKWK{_AWDSfz=42`!WeASnKEb3$kAFAo#8LBxjTXx>{FHb6w zCY-=D>d%+J9a$MwbF)!?`b&7rLaTMX5<1q+-EW#A5P6cEgn9bzA(OMmLX6Yq4TD)j zI-H(p>CC7JAsJ}io7Pm_BUjk1`KJ<# zzpmwlgG@3-HdEvyYUs9p`=&$nVf)uj_v*0f()NKj+g~yb5Pz=OWc)SwYsjayYdZz= zstT~pmr!rmw@)El;T5tTawN14e$1pp)MR9#zjse-q_~U}{-^=GpB(Fl54i3YTi-yK zg6pGwhiyyh5E15+v__A=Er?B_&7r_+u|79v6;yTkiN}7jEbIR~t!TJahtjzJse7?j znh>YefLTHq7(3#XJf zc0^-jNKTXSQqki^QzX&K?5`JG@^?zI3Zb_C6jw4^d8$!aubrpXZz$zO0^u>o4+{Wk Ld&P!o?VtQVY4{y< literal 0 HcmV?d00001 diff --git a/cortex/static/icon-512.png b/cortex/static/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..b65642da3be61e13633a1cdaa5a5a2512710aed1 GIT binary patch literal 6826 zcmeHM>tB*t*WPIAG^Wl>Z|PL(G$T4r2Q5o8G;BH;n%Se3n#amxikhZ*1e&IN&C&w1 zlti0JozgH5p{NKwP8w>Ok)eQKc@}{L5fOpsw&(pL-VgKR!@Ym(d+&R#YpuNw*Umk8 z!rkcWOl=Ie z#N|qJkE=z->mC_3{$9D}?5}5S_N=wCs`%ad_pNKKC=s6d|GT07Ec2cC?1zTMk0i}G z+k0qN@*=;XS|F6)UldcPva~9i9HAiXIQOr(;UooTH=>EgUMw4NOiz8sFQN=Tq|mp* zHxuf2F=wruO0#>qtzuh=W?`s(cf%*==ml2J_Zod;9PZ(t6dt#Zz|Kq!DBrno$Y#Te zp$m$#=|sNp=u8G~B9co%S5JA@jsz^f5!f<=(OiD1btuV^*h(@g|1Jmb;_qV-O)%h8 zYK!^Pxxr&&V^u8{an9d@`*rEEzCgd;H`tVt#Un92dA`!$8UqI9LBQoz5Z>KGI#4LB zQz{qxjV*?0wZcb5yJ4UAaGMe=OPwjpuTa{Gm>y38k0n12f@t`xoy;7rqa-kWY{o>y zZIWDOJM4h#{J>l!wtuP|=FcP~aI>QD3CHRyg~h=Cl#Q>{20js7y&R<(O)kKSbIAs? zXJ5dcLlUL@l$>1qi%fQW7y01nS2#46@{EL}id7y&r^@l_7LoxDKI4m)Zm#}N zhH*D4Gi@g8j($k#AK}&b&mXcC5%ITDTunfa8}s91^|KIWE#_QO|6%sny6%+kL1045 zSU?O1dj>D@-p!G_fbE`DKHXf_A_jAQ`K8@u*WUB3WSy{{qAA`x;%4Q18*VV+sc$9Npv7U5Aj_t&CXd4hE+D@CyVL#(9EY158u!C}Yzl#BLAdb7V z!d*toC{}`Z^6emTt>yGyk??*;lU@ngp!wmm&62bfI{Lu+JS6ZG&}8blO|8m&$xOzb zo`8A;fV?$u1hR@LzU_%H0E}L|N6GH$ef_$?;y{})jolFR8ov(rY;)Na_n1rtoJw1L zXOl}|r8+;C>6+PMF+Nko+kva|LWBfx_+1y~4Z@VI4r*yG*KuyCk8F_MQn9cD#~OVi1xa?Y7N37-AftOSo8p_Z zr@dRvXLmpVrSbwj(!Dt~nn>`={rcJRIxtbh2&;FJW#@LyBf-cy*dCdVb3vb^+nM}i-B zIf{nvVw62;-Iic`dLX|x0DI>v8S^3+OZj!94timcqo$fB>D*{%Wh+li6OYC+ovLJ%t% z#Bd}OKzHoDu2oJ3`u~Uy>ayHdmZ0eApc*6d^~?CY z0g`^CP3Fn!MdBT+Hy$tr_9vn6p6`;d8bgD`Z?|z^(LF23rX|Sb*^#F?RBQ%3CnVM2 z|Ke(JogGCB3v=FAJgjN3&Uf9N{^Z)uR^7M6;Q0%6(3+&W%9?5Q>j~qQabw1!i0dAe zpD*^TkEpAJ#1o%vm|R+%pP%ffJ4#mCpCxjMvQ#7Pg}-^bsaZ=EhvC& zLUxMvM$gfvS4>N)G$%tGdFcRmE}A>Zmtip;pfN^b1QJJVV?r0-m-R2>y%3#~stE08 zcO7u!9EHuzSo%!9#!N>5H70~NattuaZ!f6qToe*nJZw)W`;|RlBo;4LWzurEzM%4;!0q)W4-e7M4 z)|EjsU$2E`?))?O2ZVn-;h$6R&nftSa0*Tm!rO&gp)g&NfbYHRst4>(ML}Wz{nT;7 zk5JCp9aYlk%#!~PV4Qrj2j%=>b@aJ#Jesgz^WQXqAu_Gel`UV=M`id^)VBbfEPi4Q zkB0#_FV6A3s9}M83$&)ucFYTE?Z4!XsxqZJky@A?Y@!3!d9%g`thf0M!2m9!F(9Qw z;8@_As?D{^X(8zYvzyn}uP@s_{80VzK{$n#3vdUC0qHDe4xs^Nf8YS-sh3h19|h&* zIf80YgeZmfvC$4_W#UN~(67y!H4>QIrC_>EaS|uBE)KM?ELpIdsmtwnzJ&W7zye1#7a^| z;%6?*)FOWZeHl}@DG0qkXrm>VmmvCs1@_82i|^;4Vh`s!kWbF+T*M~b1!k6($ibpX zg+5|ERI#G@yLQb#A($=?zKVxEE-ur`U#<#qHYPKnbx5*v6HpbnNQ)bl)h};BfBT>g z)M;Ony+h{|rbalc7|%H!z}Wr1a;38ZgJlALgsXy!GTu3R7=p_DELHPf60%x|GKRbK zIeGvW@wjl&Pa)!7Vdg9c?*{nC#X=ffA4A0%^gNIKQOJ7h0=0KA7`gCJqK_240S3n%FIf43*!E`mT0V1$bWOGsqRlCz&ei%2fKNvnT3C z&2h<-M~`BDU~WLB0nn@u&xs=PHEGu|*MnpD#bYcXEix-><^B26O9MJTpgK*EE5mz4LVROh zdI`*@DZP zzCDtq%C=3P!1b8`HVw~f!e%I92@9qwUS5XbsEh0kXG}mCx6{9j0nPmEr1)3e>IYgfC^o;pS7XdXHV0bpHuMVpRiKOXOWPF@Ro{+=n6KGk)%&@`M2 zPK<_}U_Gl<+iXj9O4R|Q4+*G{y}RU}b8^xLu3`*F&v(XSG*DIH|wU~uWv%UZ+9iT9stc&M9EraK4T=<2Pfi3Sxbe(O*)x3iZmlqB-h00>&Z>Cd z;_LBlc~xjhp+J(c5tu!{a(`g2zV+z6NuX0p`ePTR-x>sM zFAU_>cyX|r1xdE0 zSi{jIV~Cp>jjL7MLvxUGkb2FJ@rpO{qvEr(dD!}O5PVm-H_U6%zL*`%9jgrEl=CSM zB~$HdqD^IA3wi=qeviv@PQ5t$i0Bue9mrisI3Yb95D9}Aqrcj=HK^}lF=Q@`2!-Uj4JAIxqhI=Ek# zQJ?rTGq5L)U#}e!KOyIQUntxU`i9%HRnxr#g92h>!g1+uV4Eyr0uP``E4-eEvMRvuH;@wZf_4Q><^2H{KMUeu=je_+OmFXIV45JU9h? z&mO2=Uno$&c|^`R+r5TynzIT*@z|dTPdcsDODUC(3w|6b4v%Fwz)qe2k$DK6g!PrW z1$L{qu}d?e1?|~YtzT|C^1{E|lbql$&E2KYX@c?L3PJvh3L4yYvuwP{$gwCqg;>*1 zCHkg TDKiARd;+K=Ck~T;4g2jsJw6?l literal 0 HcmV?d00001 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)) + ); +});