From 9a795dc3c975991dd0bd3830f9b30dd453609821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Gremaud?= Date: Tue, 14 Jul 2026 20:23:48 +0200 Subject: [PATCH] feat: pwa --- README.md | 30 +++++++++++++ assets/icons/icon-192.png | Bin 0 -> 997 bytes assets/icons/icon-512.png | Bin 0 -> 2918 bytes assets/icons/icon-maskable-512.png | Bin 0 -> 2565 bytes assets/manifest.webmanifest | 31 +++++++++++++ osiris/settings.py | 2 + osiris/urls.py | 10 +++++ templates/admin/base_site.html | 15 +++++++ templates/sw.js | 68 +++++++++++++++++++++++++++++ tracker/tests.py | 22 ++++++++++ 10 files changed, 178 insertions(+) create mode 100644 assets/icons/icon-192.png create mode 100644 assets/icons/icon-512.png create mode 100644 assets/icons/icon-maskable-512.png create mode 100644 assets/manifest.webmanifest create mode 100644 templates/sw.js diff --git a/README.md b/README.md index 0650ed9..3893d07 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,36 @@ DJANGO_CSRF_TRUSTED_ORIGINS=http://192.168.1.x:8000 uv run python manage.py runs Sessions last a year, so your phone stays logged in. +## Installing it on Android (PWA) + +The Today page is a progressive web app: Chrome will offer *Add to home screen* +(menu → Install app), and it then launches full-screen with its own icon, no +browser chrome, and a year-long session. No APK, no Play Store, no Flutter. + +**It must be served over HTTPS** — service workers and installability are +disabled on plain `http://` (except `localhost`). Over LAN HTTP you still get a +usable mobile site, just a bookmark rather than an installed app. The easy ways +to get a real certificate: + +```bash +# Tailscale (private to your devices, no public exposure): +tailscale serve --bg 8000 +# or Cloudflare Tunnel (public URL): +cloudflared tunnel --url http://localhost:8000 +``` + +Whichever you use, add the hostname to `DJANGO_ALLOWED_HOSTS` and +`DJANGO_CSRF_TRUSTED_ORIGINS`. + +Offline behaviour is deliberately conservative: static files are cached, and the +last-seen page is shown if you open the app with no signal, but `/api/` calls and +every write go straight to the network — a cached or replayed dose would be a +lie about whether the cat was medicated. + +If you ever do need a real APK (Play Store, or an installable file), the manifest +here is all [PWABuilder](https://www.pwabuilder.com) needs to wrap it in a +Trusted Web Activity — no Android SDK on your side. + ## Data model - `Medication` — the drug, e.g. Vetmedin. Deactivate rather than delete when a diff --git a/assets/icons/icon-192.png b/assets/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..8d625cedcc8529c553ab3c4bd9b5e09021bdb07a GIT binary patch literal 997 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rfq9Lmi(^Q|oVRx!!xlS8vsG*-}?Hi7vya!PEhs#n|6YE*{`7;zl?xUI z`Xx?Ychn=jjnl=&L3x>5U*MOyFt5Cr)_YEiNl9rz>6zuyKw3eG>DM92EL}!HK>>G?%m@Zg(RND5pNMz>ot$|zFOtR8??`}+04`nl{f17EcJ+UJ&(m}~$_AI8Tt%Y$$E!Up?_-n};8|<+7R9<9G!?jy=md7RU)qH+!TUGr0 zH1C4%HY+^SD~>M9sq@vE=ipuUZ1S6B^Y7m~ALGC1QG?Ukbuai=n=W{&T>ewwmf5lQ zZ8yqRhO(7B2)nNL+bxwb)}A5tZTQiGvu5wwds^#c*Dr~mtNqk3@Mo9);bGERxBaBb zg1e?Rb#3#$I~@ILR;}l=p!e21?bDnY@n_4`fKhiV@blwz0Rcr|>|H3cukU@s35u_> zizk0bvbcCSxUhu8**{LH4 z1c`-;ZFChZ;sY(OC?Z37O^XsJ0%3Rr23(*q@-oR>f^>Dw5Az4i@~3^zJ!|dr?Qehk zJLl`2fxh}5nSTTT`rG~3p8;UePb?4?LyEa~Ie^}V?QGA_lSD830urA2o3M7>Xk>lW z$)l1U)1vdusDz+9<~2SK-%Z*x^VS;krmX&1@w;T}9$T?$ZlY?c7{=y0#`J(o!ax=U zkaZuz2MYeD3T|vF-2_i(Un!&$p>jwqy21HPd+i+{I`@ptI>#o*a38RHr$ zGoz6Jw$j!_{St4u@gS`-0t~T09#BQM<+OX*oYfc^o3v`E&S`?l4_h98qS?G!k zR+*=^lyxhC1&k`)phIEEQb`XovN}j2%wR7^&B->AVu%TN;EI%svRtbla?m+tADKJ zXBQ#$9-~SNp*cp@DbD9i4+O^Fq`G6cMHi25Y#1l7`UL5p_9Byd@<}a!zq^I*o9WL; zHQL#o3-K>q%Yss^t{Gh?u%6v)a;+d@)?Im9qQl68iaMM@g6jnu1rZ(E_T0+4F*oBl zk=1gM_N+$GOk`z!eM&!+z|MrvNrmb>9L)&Y52&ku|!DAUW!GYC5Hck)=@`9w*DgEwhLJSi!8f96Ry~f!N|*-U3HO*p$h0A4J z*`2*8e963Ea%g?(PP%KEKRVb+8=lUsTth*^Yf<00s{R9)Z-lJSw^s2vv1X;m>dNxlpTu!5mmiQ?o4p;ySx)cLc|FQ-lM}M~Hc%0hBqG}5#rd>&q@%Kt zGbcTP>suF@Sx5xko)fF~rF7g=-!a0-7^8-017#nJf0B-G6b=y~)8*U95hv_|Ap}Of zz9E!x0)X+yup;?mD@`Rz9Sji<+dy=AdUd#p7loCW3XDmPAS%k0^x=^MpWInE-(VqU zTCl*h`R~Z z407>>5fwdntFsI>60Ye5zFC0QMGcg^i7GWswCqljt5)vWXfBd=Ol4nn@f~cnej?|p zcyXwU&@Q=e*3LZxQ)P~_z38Bac`BZx_VDT4T4~$_6J_=N)gdfUSSKvGXMUyn4jqH| z7CsZ9m~+?}IKr|obDHK>Hj$wlx!7h#gjSybM$VsrmbhAclXO2FYN=Khr)!N*VvKSd zq&*(J7r0yGOp+~Bn8k_W48fG+X6mEex*tFBNtB9bqea}^tqiDD7T?Ch{lvB~y2>mN zxT_1~)#DwcWtx9Yd-qN`U$Bj-AfpxdvKC$cz4?C#|5r>XLj~n44*ctf>hIC_Gi>(> KWZ&}Qp7{?5(Tn5& literal 0 HcmV?d00001 diff --git a/assets/icons/icon-maskable-512.png b/assets/icons/icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..41e136ce0adbb013cd61813245cf725663d933d9 GIT binary patch literal 2565 zcmeH}ZA?>F7{{M`TLesN3sn%1!gSK1j+Zqe>Y!Cn1hlB2R+*zXl$v$Y(Uqcr6bA(y z0tUm2fZbREe!zlZ;dQ~wP#A(Trx7HIARr*9MO#@*Z##M;F4^}b`@pB?frlMDTx2fj&Wl46O{SiAy!A@iE=i0&HLFS<}qh2{xwk&oOekz+EL5HhqS3@B_XM zh-*w&avy9V%g>J$93^GZ=hN+(w)w`K&l%73&3<7ZZky|_`@xkL21Dq5C5jA<=EVQy zgvf8$((dj7s33!D2>7yPzTymkKx^P$0&JtlMPF~i5nlJ3Wqrit`!z9deJiLv((3-L z5l^V?s~;puLQtTu1=vTFvI}!(_`8Tx>}CXQIxBN~bEm3j6-x>k3nub!3dP?m{M zH`y#q%CE)+xyUPMMcUV%y+P%p2j`J6%I#DPNRZC>y{u6ti)+u2C=KxzH_xhig^X1R zZZJJOsr|;xqrU)Zn<6`#DVRT5e%)}{cp2GBhk9x2z2w&3tPO}DAg?DxVps15qFdz5 zHftC;Qk>rsIuhEP#Q>k{5B0CT(fTz|-TOe{ZUyTjQ}ndr0wj+tqeI270El3Z)3uI6 zMt-;N<2}H?Q(2ezGv~1cj-%KX(K9b+MBo^(@x;}3EQKszG3`U;B)dkTOY-QzJZ`TD zwg%dxRDF%n#*dm$pu+InX2WUq$PwMJJu<}W{1W#xxV1+rhxPq$@Wyv8zyXNCt2xu}Hc*Rk0ds8TXa< ztYEIGF(pr8^Ihf``-vu&i(B)dNBE8y_6AUYV8}7Btbb)s)5PA4V zUNccbq5t8VNzO@DkL>wc5DEV*XAXD**w1OFGbjL9@?xSs>w9Jhf z(OOm*s9oL`Ek^`jba;zGVCBF%YX9bsU!@jj0Jijb%F%5l;MLsp?${KELBvFW=7~BR zjDtb`g<55k6-c~RM@vzdQZtJut>Av+$VYUG7ALy$VVT$67W``5lU%;#k^( zTb=rb^G5z~IEr0T_0AryG;J6ktv1O1=y}nnF{tC>aw7OVC@#&W$s`u!&U}w$ED)zn z?oI;?AuDW%Fk^$&Cdzgq;R(m_dHzsT@}TSr5%THIaEk!qqVjUS$>9QEQ4WvFN&#d3 zl8$_kN^}>eeMQ(L|C$*!NH8~R=@Wy;#(a<|@>^5;5E=ZVb0r5+D9xvKun5VNEz(qABV@*;U>k&(u*(VzjqM4gBPgUE;#o$h;y|X literal 0 HcmV?d00001 diff --git a/assets/manifest.webmanifest b/assets/manifest.webmanifest new file mode 100644 index 0000000..e87304e --- /dev/null +++ b/assets/manifest.webmanifest @@ -0,0 +1,31 @@ +{ + "name": "Osiris", + "short_name": "Osiris", + "description": "Medication and pulse tracking for Osiris the cat.", + "start_url": "/admin/today/", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#202a35", + "theme_color": "#202a35", + "icons": [ + { + "src": "/static/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/static/icons/icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/osiris/settings.py b/osiris/settings.py index 98b4bc7..7cdddc4 100644 --- a/osiris/settings.py +++ b/osiris/settings.py @@ -129,6 +129,8 @@ USE_TZ = True STATIC_URL = "static/" STATIC_ROOT = BASE_DIR / "static" +# PWA icons and manifest live here; STATIC_ROOT is only the collectstatic output. +STATICFILES_DIRS = [BASE_DIR / "assets"] # Default primary key field type DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/osiris/urls.py b/osiris/urls.py index 1940703..7ab8d99 100644 --- a/osiris/urls.py +++ b/osiris/urls.py @@ -1,6 +1,7 @@ from django.contrib import admin from django.shortcuts import redirect from django.urls import include, path +from django.views.generic import TemplateView from osiris.api import api @@ -10,6 +11,15 @@ admin.site.index_title = "Medication & pulse tracking" urlpatterns = [ path("api/", api.urls), + # Must be served from the root: a worker under /static/ could only control + # /static/, not the /admin/ pages it exists to cache. + path( + "sw.js", + TemplateView.as_view( + template_name="sw.js", content_type="application/javascript" + ), + name="service-worker", + ), # Sits under /admin/ so it inherits the admin's styling and login, but must # come before the admin's own catch-all patterns. path("admin/", include("tracker.urls")), diff --git a/templates/admin/base_site.html b/templates/admin/base_site.html index 0594374..aaec837 100644 --- a/templates/admin/base_site.html +++ b/templates/admin/base_site.html @@ -1,7 +1,22 @@ {% extends "admin/base.html" %} +{% load static %} {% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Osiris') }}{% endblock %} +{% block extrahead %} +{{ block.super }} + + + + + + +{% endblock %} + {% block branding %} {% if user.is_anonymous %} diff --git a/templates/sw.js b/templates/sw.js new file mode 100644 index 0000000..6e3f6f1 --- /dev/null +++ b/templates/sw.js @@ -0,0 +1,68 @@ +// Served from / (not /static/) so its scope covers /admin/today/. +const CACHE = "osiris-v1"; + +self.addEventListener("install", (event) => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key))) + ) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + + // Never touch writes or the API: a stale or replayed dose would be a lie. + if (request.method !== "GET" || new URL(request.url).pathname.startsWith("/api/")) { + return; + } + + // Static assets change rarely: serve from cache, refresh in the background. + if (new URL(request.url).pathname.startsWith("/static/")) { + event.respondWith( + caches.open(CACHE).then(async (cache) => { + const cached = await cache.match(request); + const network = fetch(request) + .then((response) => { + if (response.ok) cache.put(request, response.clone()); + return response; + }) + .catch(() => cached); + return cached || network; + }) + ); + return; + } + + // Pages: always prefer the live version, fall back to the last one seen so the + // app still opens (read-only) with no signal. + event.respondWith( + fetch(request) + .then((response) => { + if (response.ok) { + const copy = response.clone(); + caches.open(CACHE).then((cache) => cache.put(request, copy)); + } + return response; + }) + .catch(async () => { + const cached = await caches.match(request); + return ( + cached || + new Response( + "" + + "" + + "

Offline

Osiris needs a connection to load. Try again once you're back online.

", + { headers: { "Content-Type": "text/html; charset=utf-8" } } + ) + ); + }) + ); +}); diff --git a/tracker/tests.py b/tracker/tests.py index 4127c71..812101a 100644 --- a/tracker/tests.py +++ b/tracker/tests.py @@ -229,3 +229,25 @@ class TodayPageTests(TrackerTestCase): response = self.client.get(reverse("tracker:today")) self.assertContains(response, "