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
+20 -1
View File
@@ -78,11 +78,30 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/presence", s.auth(s.handlePresence))
mux.HandleFunc("GET /{$}", s.serveIndex)
mux.Handle("GET /", http.FileServerFS(s.webFS))
mux.Handle("GET /", s.staticHandler())
return s.wrap(mux)
}
// staticHandler serve o FS embutido com os cabeçalhos que PWA exige:
// sw.js nunca pode ser cacheado pelo browser (a detecção de nova versão é por
// bytes) e o manifest ganha o MIME application/manifest+json.
func (s *Server) staticHandler() http.Handler {
files := http.FileServerFS(s.webFS)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/sw.js":
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
files.ServeHTTP(w, r)
return
case "/manifest.webmanifest":
w.Header().Set("Content-Type", "application/manifest+json; charset=utf-8")
}
files.ServeHTTP(w, r)
})
}
// wrap aplica middlewares globais: recuperação, headers de segurança e log.
func (s *Server) wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+2 -2
View File
@@ -3,7 +3,7 @@ package web
import "embed"
// FS contém index.html, css/ e js/ na raiz.
// FS contém index.html, css/, js/, icons/, sw.js e manifest.webmanifest na raiz.
//
//go:embed index.html css js
//go:embed index.html manifest.webmanifest sw.js css js icons
var FS embed.FS
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

+9
View File
@@ -6,6 +6,14 @@
<meta name="color-scheme" content="light">
<title>Notas — editor estilo Mousepad</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='2' fill='%233584e4'/><path d='M3 4h10M3 7h10M3 10h7' stroke='white' stroke-width='1.5' stroke-linecap='round'/></svg>">
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#3584e4">
<meta name="mobile-web-app-capable" content="yes">
<meta name="application-name" content="Notas">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Notas">
<link rel="apple-touch-icon" href="/icons/icon-192.png">
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
@@ -171,6 +179,7 @@
<div class="menu" data-menu="ajuda">
<button type="button" class="menu-btn">Ajuda</button>
<ul class="menu-dropdown">
<li data-action="install" class="hidden">Instalar Aplicativo…</li>
<li data-action="about">Sobre o Notas</li>
<li data-action="logout">Sair (esquecer token)</li>
</ul>
+49
View File
@@ -1792,6 +1792,15 @@
'toggle-gutter': () => { prefs.gutter = !prefs.gutter; persistPrefs(); applyPrefs(); },
'toggle-wrap': () => { prefs.wrap = !prefs.wrap; persistPrefs(); applyPrefs(); },
'prefs': openPrefs,
'install': async () => {
if (!deferredPrompt) { toast('Use o ícone de instalação da barra de endereço.'); return; }
const p = deferredPrompt;
deferredPrompt = null;
refreshInstallUI();
p.prompt();
const { outcome } = await p.userChoice;
if (outcome === 'accepted') toast('Instalando o Notas como aplicativo…');
},
'toggle-edit': () => setMode('edit'),
'toggle-wysiwyg': () => setMode('wysiwyg'),
'toggle-preview': () => setMode('preview'),
@@ -1938,6 +1947,44 @@
E.authorInput.addEventListener('change', () =>
localStorage.setItem('notas_author', E.authorInput.value.trim()));
/* ------------------------------------------------------------ PWA */
const isStandalone = () =>
window.matchMedia('(display-mode: standalone)').matches ||
window.matchMedia('(display-mode: window-controls-overlay)').matches ||
window.navigator.standalone === true;
let deferredPrompt = null;
const miInstall = document.querySelector('.menu-dropdown [data-action="install"]');
function refreshInstallUI() {
if (miInstall) miInstall.classList.toggle('hidden', isStandalone() || !deferredPrompt);
}
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault(); // prompt próprio via menu Ajuda
deferredPrompt = e;
refreshInstallUI();
});
window.addEventListener('appinstalled', () => {
deferredPrompt = null;
refreshInstallUI();
toast('Notas instalado ✔ procure pelo menu do sistema');
});
if ('serviceWorker' in navigator && location.protocol.startsWith('http')) {
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => { /* sem SW: app continua */ });
navigator.serviceWorker.addEventListener('message', (e) => {
if (e.data === 'notas:updated') toast('App atualizado para a nova versão');
});
}
window.addEventListener('offline', () => toast('Sem conexão — o app abre offline; salvar precisa do servidor'));
window.addEventListener('online', () => { toast('Conexão restabelecida'); checkFreshness(); });
const wantsNew = new URLSearchParams(location.search).get('new') === '1';
if (wantsNew) history.replaceState(null, '', location.pathname);
/* ------------------------------------------------------------ boot */
async function boot() {
@@ -1946,6 +1993,8 @@
await restoreTabs();
updateStatus();
state.booted = true;
refreshInstallUI();
if (wantsNew) await newDraftTab();
connectEvents();
sendPresence();
setInterval(sendPresence, 10000); // heartbeat de presença
+31
View File
@@ -0,0 +1,31 @@
{
"name": "Notas — editor estilo Mousepad",
"short_name": "Notas",
"description": "Gerenciador de notas com categorias em árvore, tags, Markdown/HTML, revisões estilo Git e sincronização em tempo real.",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
"background_color": "#f6f5f4",
"theme_color": "#3584e4",
"lang": "pt-BR",
"dir": "ltr",
"categories": ["productivity", "utilities"],
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" },
{ "src": "/icons/monochrome-512.png", "sizes": "512x512", "type": "image/png", "purpose": "monochrome" }
],
"shortcuts": [
{
"name": "Nova nota",
"short_name": "Nova",
"url": "/?new=1",
"description": "Abre o editor em uma nota nova",
"icons": [{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }]
}
]
}
+79
View File
@@ -0,0 +1,79 @@
/* Service Worker do Notas — caches versionados.
* BUMP de CACHE em todo deploy que mude shell/assets (o activate limpa os antigos).
*/
const CACHE = 'notas-shell-v1';
const SHELL = [
'/',
'/css/app.css',
'/js/app.js',
'/manifest.webmanifest',
'/icons/icon-192.png',
'/icons/icon-512.png',
'/icons/maskable-192.png',
'/icons/maskable-512.png',
'/icons/monochrome-512.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE)
.then((cache) => cache.addAll(SHELL))
.then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
.then(() => self.clients.matchAll({ type: 'window' }))
.then((clients) => {
for (const client of clients) client.postMessage('notas:updated');
}),
);
});
self.addEventListener('message', (event) => {
if (event.data === 'notas:skip-waiting') self.skipWaiting();
});
self.addEventListener('fetch', (event) => {
const req = event.request;
const url = new URL(req.url);
// 1. API e SSE: NUNCA interceptar/cache — dados devem ser sempre frescos
// (interceptar SSE quebraria o stream de tempo real).
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
// 2. Navegação (abrir o app): network-first com fallback ao shell em cache (offline).
if (req.mode === 'navigate' || req.destination === 'document') {
event.respondWith(
fetch(req)
.then((resp) => {
const copy = resp.clone();
caches.open(CACHE).then((c) => c.put('/', copy));
return resp;
})
.catch(() => caches.match('/')),
);
return;
}
// 3. Estáticos (css/js/ícones/fontes): stale-while-revalidate —
// mostra o cache na hora e atualiza em background.
event.respondWith(
caches.match(req).then((hit) => {
const refresh = fetch(req)
.then((resp) => {
if (resp && resp.ok) {
const copy = resp.clone();
caches.open(CACHE).then((c) => c.put(req, copy));
}
return resp;
})
.catch(() => hit);
return hit || refresh;
}),
);
});