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:
1 parent
63fd024f68
commit
18d33ed141
12 files changed
+243
-3
No files matched your search
@@ -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
@@ -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
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user