diff --git a/README.md b/README.md
index bae53c0..52bc3aa 100644
--- a/README.md
+++ b/README.md
@@ -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 ``.
+- **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//` (content-addressed, como objetos do
diff --git a/internal/api/server.go b/internal/api/server.go
index 0b6c852..5bcb6ed 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -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) {
diff --git a/web/embed.go b/web/embed.go
index f1e72d7..4472076 100644
--- a/web/embed.go
+++ b/web/embed.go
@@ -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
diff --git a/web/icons/icon-192.png b/web/icons/icon-192.png
new file mode 100644
index 0000000..5aa11d3
Binary files /dev/null and b/web/icons/icon-192.png differ
diff --git a/web/icons/icon-512.png b/web/icons/icon-512.png
new file mode 100644
index 0000000..ec88508
Binary files /dev/null and b/web/icons/icon-512.png differ
diff --git a/web/icons/maskable-192.png b/web/icons/maskable-192.png
new file mode 100644
index 0000000..9dd8630
Binary files /dev/null and b/web/icons/maskable-192.png differ
diff --git a/web/icons/maskable-512.png b/web/icons/maskable-512.png
new file mode 100644
index 0000000..7afade6
Binary files /dev/null and b/web/icons/maskable-512.png differ
diff --git a/web/icons/monochrome-512.png b/web/icons/monochrome-512.png
new file mode 100644
index 0000000..8ef675d
Binary files /dev/null and b/web/icons/monochrome-512.png differ
diff --git a/web/index.html b/web/index.html
index ff070ff..57ea50a 100644
--- a/web/index.html
+++ b/web/index.html
@@ -6,6 +6,14 @@
Notas — editor estilo Mousepad
+
+
+
+
+
+
+
+
@@ -171,6 +179,7 @@
+
Instalar Aplicativo…
Sobre o Notas
Sair (esquecer token)
diff --git a/web/js/app.js b/web/js/app.js
index e355187..a4c7d42 100644
--- a/web/js/app.js
+++ b/web/js/app.js
@@ -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
diff --git a/web/manifest.webmanifest b/web/manifest.webmanifest
new file mode 100644
index 0000000..ee99348
--- /dev/null
+++ b/web/manifest.webmanifest
@@ -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" }]
+ }
+ ]
+}
diff --git a/web/sw.js b/web/sw.js
new file mode 100644
index 0000000..1662cc6
--- /dev/null
+++ b/web/sw.js
@@ -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;
+ }),
+ );
+});