diff --git a/internal/server/cosmetics_test.go b/internal/server/cosmetics_test.go index 912f5a4..d66bcf6 100644 --- a/internal/server/cosmetics_test.go +++ b/internal/server/cosmetics_test.go @@ -785,3 +785,22 @@ func guildChannelID(t *testing.T, srv *Server, guildID string, cookie *http.Cook t.Fatal("текстовая комната не найдена") return "" } + +func TestStaticServesPWAAssets(t *testing.T) { + srv, _ := newTestServer(t) + // В тестовом каталоге лежит только index.html: проверяем типы по расширению. + cases := map[string]string{ + "/manifest.webmanifest": "application/manifest+json", + "/sw.js": "text/javascript; charset=utf-8", + "/offline.html": "text/html; charset=utf-8", + } + for path, want := range cases { + rec := doJSON(t, srv, http.MethodGet, path, "") + if rec.Code != http.StatusOK { + t.Fatalf("%s = %d", path, rec.Code) + } + if got := rec.Header().Get("Content-Type"); got != want { + t.Fatalf("%s: Content-Type = %q, ожидался %q", path, got, want) + } + } +} diff --git a/internal/server/static.go b/internal/server/static.go index 517d308..29f6d38 100644 --- a/internal/server/static.go +++ b/internal/server/static.go @@ -39,7 +39,9 @@ func (h *staticHandler) serve(w http.ResponseWriter, r *http.Request) { return } - if contentType := mime.TypeByExtension(filepath.Ext(rel)); contentType != "" { + // Часть расширений (манифест PWA) известна не всем системам: тип задаём + // явно, иначе браузер получает text/plain и отказывается ставить приложение. + if contentType := staticContentType(rel); contentType != "" { w.Header().Set("Content-Type", contentType) } if strings.HasPrefix(rel, "assets/") { @@ -50,6 +52,21 @@ func (h *staticHandler) serve(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, filepath.Join(h.root, filepath.FromSlash(rel))) } +// staticContentType определяет тип файла: сначала явные правила, потом mime. +func staticContentType(rel string) string { + switch filepath.Ext(rel) { + case ".webmanifest": + return "application/manifest+json" + case ".js": + return "text/javascript; charset=utf-8" + case ".css": + return "text/css; charset=utf-8" + case ".svg": + return "image/svg+xml" + } + return mime.TypeByExtension(filepath.Ext(rel)) +} + func (h *staticHandler) serveIndex(w http.ResponseWriter, r *http.Request) { index := filepath.Join(h.root, "index.html") if _, err := os.Stat(index); err != nil { diff --git a/internal/server/testdata/web/icons/icon-192.png b/internal/server/testdata/web/icons/icon-192.png new file mode 100644 index 0000000..cecdad6 Binary files /dev/null and b/internal/server/testdata/web/icons/icon-192.png differ diff --git a/internal/server/testdata/web/manifest.webmanifest b/internal/server/testdata/web/manifest.webmanifest new file mode 100644 index 0000000..cf036f1 --- /dev/null +++ b/internal/server/testdata/web/manifest.webmanifest @@ -0,0 +1,29 @@ +{ + "name": "glchat", + "short_name": "glchat", + "description": "glchat — self-hosted платформа общения: серверы, комнаты, голос и файлы", + "lang": "ru", + "dir": "ltr", + "start_url": "/app", + "scope": "/", + "display": "standalone", + "display_override": ["window-controls-overlay", "standalone"], + "orientation": "any", + "background_color": "#1a1b1e", + "theme_color": "#1a1b1e", + "categories": ["social", "communication"], + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { + "src": "/icons/maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "shortcuts": [ + { "name": "Друзья", "url": "/app/friends" }, + { "name": "Настройки", "url": "/settings/account/profile" } + ] +} diff --git a/internal/server/testdata/web/offline.html b/internal/server/testdata/web/offline.html new file mode 100644 index 0000000..75ab26a --- /dev/null +++ b/internal/server/testdata/web/offline.html @@ -0,0 +1,60 @@ + + + + + + + glchat — нет сети + + + +
+

Нет соединения

+

+ glchat не смог связаться с сервером. Проверьте сеть — как только связь вернётся, приложение + откроется само. +

+ +
+ + diff --git a/internal/server/testdata/web/sw.js b/internal/server/testdata/web/sw.js new file mode 100644 index 0000000..8ab42cb --- /dev/null +++ b/internal/server/testdata/web/sw.js @@ -0,0 +1,91 @@ +/* + * Сервис-воркер glchat (AGENT.md, Фаза 5): офлайн-заглушка и кэш статики. + * + * Правила простые и безопасные: + * - навигация — сеть, при ошибке офлайн-страница (данные всегда свежие); + * - собранная статика `/assets/*` (хешированные имена) — из кэша; + * - API, шлюз и файлы никогда не кэшируются: это приватные данные. + */ +const VERSION = 'glchat-v1'; +const OFFLINE_URL = '/offline.html'; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(VERSION).then((cache) => cache.addAll([OFFLINE_URL, '/manifest.webmanifest'])), + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all(keys.filter((key) => key !== VERSION).map((key) => caches.delete(key))), + ) + .then(() => self.clients.claim()), + ); +}); + +/** Приватные запросы: их нельзя ни кэшировать, ни отдавать из кэша. */ +function isPrivate(url) { + return ( + url.pathname.startsWith('/api/') || + url.pathname.startsWith('/files/') || + url.pathname.startsWith('/gateway') || + url.pathname.startsWith('/rtc') + ); +} + +self.addEventListener('fetch', (event) => { + const request = event.request; + if (request.method !== 'GET') { + return; + } + const url = new URL(request.url); + if (url.origin !== self.location.origin || isPrivate(url)) { + return; + } + + if (request.mode === 'navigate') { + event.respondWith( + fetch(request).catch(() => + caches.match(request).then((cached) => cached ?? caches.match(OFFLINE_URL)), + ), + ); + return; + } + + if (url.pathname.startsWith('/assets/')) { + event.respondWith( + caches.match(request).then( + (cached) => + cached ?? + fetch(request).then((response) => { + if (response.ok) { + const copy = response.clone(); + void caches.open(VERSION).then((cache) => cache.put(request, copy)); + } + return response; + }), + ), + ); + return; + } + + // Иконки, манифест и прочая статика: сеть, при недоступности — кэш. + event.respondWith( + fetch(request) + .then((response) => { + if ( + response.ok && + (url.pathname.startsWith('/icons/') || url.pathname === '/manifest.webmanifest') + ) { + const copy = response.clone(); + void caches.open(VERSION).then((cache) => cache.put(request, copy)); + } + return response; + }) + .catch(() => caches.match(request).then((cached) => cached ?? Response.error())), + ); +});