feat(pwa): app instalável com service worker e shell offline

- manifest.webmanifest: standalone, theme/background color, ícones 192/512
  any+maskable+monochrome (PNGs em web/icons) e atalho 'Nova nota' (?new=1)
- sw.js: cache versionado (notas-shell-v1); navegação network-first com
  fallback ao shell (offline), estáticos stale-while-revalidate, /api/* e
  SSE sempre passthrough; activate limpa caches e notifica janelas
- app.js: registro do SW, beforeinstallprompt com item 'Instalar Aplicativo…'
  no menu Ajuda (escondido em standalone/após instalar), toast de atualização,
  aviso offline/online, deep-link ?new=1
- index.html: meta tags theme-color/mobile-web-app/apple-touch-icon/manifest
- server: staticHandler com MIME application/manifest+json e
  Cache-Control no-cache para /sw.js; embed inclui manifest, sw e ícones
- README: seção PWA + orientação de serving (Go atual e equivalente Rust)
This commit is contained in:
lgcosta committed 2026-11-05 08:04:27 -03:00
1 parent 63fd024f68
commit 18d33ed141
12 files changed
+243 -3

No files matched your search

+53
View File
@@ -223,6 +223,59 @@ curl -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
# (clientes que ignoram o evento podem sobrescrever a própria edição sem querer)
```
## PWA — instalável e offline
O app é um **PWA completo**: instalável no Linux/Windows/macOS/Android (menu **Ajuda →
Instalar Aplicativo…**, ou o ícone da barra de endereço), com janela `standalone` sem chrome
do navegador e atalho de área de trabalho "Nova nota" (`/?new=1`).
- **`web/manifest.webmanifest`** — `name/short_name/description/start_url/scope/theme_color/
background_color`, `display: standalone` (+ `display_override` com window-controls-overlay),
ícones **192/512 em `any`, `maskable` e `monochrome`** (PNGs reais em `web/icons/`) e atalho.
- **`web/sw.js`** — Service Worker com cache versionado (`notas-shell-v1`):
- `install` pré-cacheia o shell e `skipWaiting`; `activate` expurga caches antigos,
`clients.claim()` e avisa as janelas (`notas:updated` → toast "App atualizado");
- navegação: **network-first com fallback ao shell** (abre offline);
- estáticos: **stale-while-revalidate** (carregamento instantâneo);
- **`/api/*` nunca é interceptado** — dados sempre frescos e o SSE de tempo real não quebra.
- **Ao publicar mudanças de assets, bump na constante `CACHE`** do `sw.js`.
- **Meta tags** em `index.html`: `theme-color`, `mobile-web-app-capable`,
`apple-mobile-web-app-*`, `apple-touch-icon` e `<link rel="manifest">`.
- **Offline UX**: shell + interface funcionam sem rede; ações que precisam do servidor mostram
toast claro ("Sem conexão — o app abre offline; salvar precisa do servidor").
### Servindo os arquivos (backend Go atual)
Já implementado em `internal/api/server.go` (`staticHandler`):
- `sw.js` → `Cache-Control: no-cache, no-store, must-revalidate` + `Content-Type:
text/javascript` (a detecção de update do SW é por bytes — nunca cacheie!);
- `manifest.webmanifest` → `Content-Type: application/manifest+json; charset=utf-8`
(o Go não conhece a extensão `.webmanifest` por padrão);
- tudo vem do `go:embed` (`web/embed.go` inclui `manifest.webmanifest sw.js icons`).
- O Service Worker só registra em origem segura: **HTTPS ou `localhost`/`127.0.0.1`** — em IP
de LAN puro (`http://192.168.x.x`) o navegador bloqueia SW; use um túnel/reverse proxy TLS
para testar instalação real fora de localhost.
### Equivalente em Rust (axum, caso o backend migre)
```rust
// MIME do manifest + no-cache no SW
async fn manifest() -> impl IntoResponse {
([(header::CONTENT_TYPE, "application/manifest+json")], include_str!("../web/manifest.webmanifest"))
}
async fn sw() -> impl IntoResponse {
[
(header::CONTENT_TYPE, "text/javascript"),
(header::CACHE_CONTROL, "no-cache, no-store, must-revalidate"),
]
}
```
No actix-web, o `fs::Files` já define MIME por extensão, mas registre antes:
`mime::BABFILE` não — use `mime_guess::init::detect_EXTENSION("webmanifest",
"application/manifest+json")` e um `wrap_fn` que sobrepõe `Cache-Control` quando o caminho for
`/sw.js`. (No `tower-http` `SetResponseHeaderLayer` com `Match` por path é o caminho limpo.)
## Como funciona o versionamento
- O conteúdo é guardado em `data/blobs/<aa>/<hash-sha256>` (content-addressed, como objetos do