From dd4432452cbe3446c17c57e9cc658ce6f0dd6039 Mon Sep 17 00:00:00 2001 From: grendervill Date: Mon, 21 Sep 2026 19:45:25 +0300 Subject: [PATCH] =?UTF-8?q?fix(web):=20=D0=BC=D0=B0=D0=BD=D0=B8=D1=84?= =?UTF-8?q?=D0=B5=D1=81=D1=82=20PWA=20=D0=BE=D1=82=D0=B4=D0=B0=D1=91=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D1=81=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=BC=20=D1=82=D0=B8=D0=BF=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go определяет .webmanifest как text/plain, из-за чего браузер мог отказаться ставить приложение: тип задаём явно вместе с js/css/svg. --- internal/server/cosmetics_test.go | 19 ++++ internal/server/static.go | 19 +++- .../server/testdata/web/icons/icon-192.png | Bin 0 -> 1464 bytes .../server/testdata/web/manifest.webmanifest | 29 ++++++ internal/server/testdata/web/offline.html | 60 ++++++++++++ internal/server/testdata/web/sw.js | 91 ++++++++++++++++++ 6 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 internal/server/testdata/web/icons/icon-192.png create mode 100644 internal/server/testdata/web/manifest.webmanifest create mode 100644 internal/server/testdata/web/offline.html create mode 100644 internal/server/testdata/web/sw.js 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 0000000000000000000000000000000000000000..cecdad6e0d72db7c653f3a172187a819868300fa GIT binary patch literal 1464 zcmaJ>X;jh)5dSL_q=HgvYYX1{v`l+;En)`df#&Ajv5IMSSk@-4Aey^~W1|Vz zaWtgt{x6KGR_~R#6wQUIq)x(g>$-w+PpANobhd0W#`sOO)5G?;1bHG= zin1)WJ&((cEcOdm=_#8U$;u^4YPAB6EXp9ie37R*vFZYIACY|N0p#Q-xcsT@WJjtZ z@{}|_$0gXCkISuf#zbi^F;wqkE71`c5tVNq#B88#snjlSUeA=;IB~Vht5?CIh>Qs8 zMjYvAT2+?Laxd{`;8&8)jbInFrLMa~wYcdH4vDEAvk~1lc#M7wb11ARZO~ZRn^!#g z0)@OGw!!K)7K*uki>huEDj--!CR+!30L--k?81JTx(y>F5%nfF=~zth04^D*v6}^^ zyl243Sp`|mMhcDU(%S(Ra|S(J$jGSd=`+GI`de&)c|uvzFv_A(nu!Bmw2q}iuo_nw zcaFY-MO2V51OmMK(HTHJzpX6-@QX=fUsgOLSzBmu*Kgu`UODiKCoPl$#zgq#J7!2>!?=YerM7qu{8l8jup%?c_ zX^^Q=Xc3y2t+OlY6q9Nfs`qHe~Ml$9Iix%YB{Zw06jc&2eIfdl};%fcbsG;Yf#1P zJ5Rdti2y#xn;)ZBM%xXbCOFys5|zVlCZpxJ#(~8}8)t_JXc6JOpKOgY z{RAnODW-R8)i33@0{mdOO!z8Vg%&(ga?M2tg$JcE#?ygZkfIrPbYGtEb#5(HMVyp` z_#!G^49oQwxgin!z`lPFl$4IXW`9ic5|5(4p8jD}E72YBUBtVs_DPKrtW}cX*4aKT z#v|CgDBABIIpsG^C=1_&jIlkj;zW6F)$VCW4$qVWJ~=3XBbJ1iQM}xrv=cZ3U(`}i lJv0=sQ&e2Q`ie%T;`tVGZi2}2Gx(8#;DAv7hFuA{{{kC6y> + + + + + + 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())), + ); +});