From 1562519f087f7a4b7a73dd0cb292c47439d4147a Mon Sep 17 00:00:00 2001 From: eezd Date: Mon, 19 Oct 2026 13:22:56 +0800 Subject: [PATCH] Add installable PWA shell with safe updates and offline status --- internal/api/api.go | 5 +- internal/api/api_test.go | 36 +++++++++++++ internal/api/static.go | 5 +- web/index.html | 3 ++ web/public/icon.svg | 4 ++ web/public/icons/apple-touch-icon.png | Bin 0 -> 564 bytes web/public/icons/icon-192.png | Bin 0 -> 611 bytes web/public/icons/icon-512.png | Bin 0 -> 2277 bytes web/public/icons/maskable-512.png | Bin 0 -> 2277 bytes web/public/manifest.webmanifest | 16 ++++++ web/src/app/App.vue | 12 ++++- web/src/app/AppStatus.vue | 62 +++++++++++++++++++++++ web/src/app/api.ts | 2 +- web/src/app/app.css | 5 ++ web/src/app/pwa.ts | 65 ++++++++++++++++++++++++ web/src/app/workspace.ts | 6 +++ web/src/service-worker.js | 70 ++++++++++++++++++++++++++ web/vite.config.ts | 24 +++++++++ 18 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 web/public/icon.svg create mode 100644 web/public/icons/apple-touch-icon.png create mode 100644 web/public/icons/icon-192.png create mode 100644 web/public/icons/icon-512.png create mode 100644 web/public/icons/maskable-512.png create mode 100644 web/public/manifest.webmanifest create mode 100644 web/src/app/AppStatus.vue create mode 100644 web/src/app/pwa.ts create mode 100644 web/src/service-worker.js diff --git a/internal/api/api.go b/internal/api/api.go index 3361060..fe2de22 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -124,6 +124,9 @@ func (s *Server) Handler(static fs.FS) http.Handler { func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() + if strings.HasPrefix(r.URL.Path, "/api/") { + h.Set("Cache-Control", "no-store") + } h.Set("X-Content-Type-Options", "nosniff") h.Set("Referrer-Policy", "same-origin") h.Set("X-Frame-Options", "DENY") @@ -394,7 +397,7 @@ func (s *Server) serveRaw(w http.ResponseWriter, r *http.Request, rel string) { if ct := mime.TypeByExtension(path.Ext(info.Name())); ct != "" { h.Set("Content-Type", ct) } - h.Set("Cache-Control", "private, no-cache") + h.Set("Cache-Control", "no-store") http.ServeContent(w, r, info.Name(), info.ModTime(), f) } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 50544bf..f09170d 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -1,6 +1,7 @@ package api import ( + "compress/gzip" "encoding/json" "io" "net/http" @@ -139,6 +140,41 @@ func TestOpenAPICoversRoutes(t *testing.T) { } } +func TestAPIResponsesAreNotCached(t *testing.T) { + f := newFixture(t) + f.write("笔记.md", "# 正文") + // 登录状态与目录曾在 Firefox 离线时命中 HTTP 缓存,误进入无法加载的工作区。 + for _, path := range []string{"/api/v1/auth/state", "/api/v1/tree", "/api/v1/files/笔记.md", "/api/v1/raw/笔记.md"} { + res := f.do(f.admin, "GET", path, "", nil) + if res.StatusCode != http.StatusOK || res.Header.Get("Cache-Control") != "no-store" { + t.Errorf("%s:状态 %d,缓存策略 %q", path, res.StatusCode, res.Header.Get("Cache-Control")) + } + } +} + +func TestCompressedManifestContentType(t *testing.T) { + body := strings.Repeat(" ", 300) + `{"name":"RawMark","display":"standalone"}` + handler := newStaticHandler(fstest.MapFS{"manifest.webmanifest": {Data: []byte(body)}}) + req := httptest.NewRequest("GET", "/manifest.webmanifest", nil) + req.Header.Set("Accept-Encoding", "gzip") + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + res := recorder.Result() + defer res.Body.Close() + if res.Header.Get("Content-Type") != "application/manifest+json" || res.Header.Get("Content-Encoding") != "gzip" { + t.Fatalf("清单响应头不正确:%v", res.Header) + } + zr, err := gzip.NewReader(res.Body) + if err != nil { + t.Fatal(err) + } + defer zr.Close() + var manifest map[string]string + if err := json.NewDecoder(zr).Decode(&manifest); err != nil || manifest["name"] != "RawMark" { + t.Fatalf("压缩清单无法解析:%v", err) + } +} + func TestShareAccess(t *testing.T) { f := newFixture(t) f.write("目录/笔记.md", "# 标题\n\n![](笔记.assets/图.png)\n") diff --git a/internal/api/static.go b/internal/api/static.go index 715d3b8..a639948 100644 --- a/internal/api/static.go +++ b/internal/api/static.go @@ -68,7 +68,10 @@ func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if share { hd.Set("X-Robots-Tag", "noindex") } - if ct := mime.TypeByExtension(path.Ext(name)); ct != "" { + if path.Ext(name) == ".webmanifest" { + // Go 的 MIME 表未必包含此扩展名,不能让 ServeContent 对压缩后的数据嗅探类型。 + hd.Set("Content-Type", "application/manifest+json") + } else if ct := mime.TypeByExtension(path.Ext(name)); ct != "" { hd.Set("Content-Type", ct) } hd.Add("Vary", "Accept-Encoding") diff --git a/web/index.html b/web/index.html index 418c210..ad4d015 100644 --- a/web/index.html +++ b/web/index.html @@ -6,6 +6,9 @@ RawMark + + +
diff --git a/web/public/icon.svg b/web/public/icon.svg new file mode 100644 index 0000000..944f4ba --- /dev/null +++ b/web/public/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/public/icons/apple-touch-icon.png b/web/public/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b1571024a677e3706db85572b07b6dc1bcf077f0 GIT binary patch literal 564 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD2}o{QKQWbof$_Jei(^Q|t+#g%@-`a?xH@ik z63kQ(|Fw9li3{^Zxebor(wBrc9PM*xaK82J=FjzIU`_)_1WZWuCCg1OaCs=Pnx)T2=C5{PB=i^&0VDY12ak4?pfrIQalixZtD>Jgo7kmH_ zXa2mO;4PfVxcxCWjBx~8lQWVmI66EC#*T#yE7-KW_bmS=X0+)_c1r?N1B0ilpUXO@ GgeCxQ;?Vg3 literal 0 HcmV?d00001 diff --git a/web/public/icons/icon-192.png b/web/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..063e07c9e356d7f86bae55822aed537cbebb2301 GIT binary patch literal 611 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rfl15L#WAGf*4w**yoVe_TrNgA zvatO)D1Xf$gx8?)-8t)%q8k#=xo1jSnp^~tM?eJ21s7)^31&ix{SrJ14OZuwFVr;yrH?LW%CK7SA%THWWG=5l0|OI> zLW7?Cnb$H9U1xW)0Y$19KKoV~~_V9RikIe>9NV+n&3yFv-WEj|GgpgS+LR~xegSeZF9EcFI4 zHhU{ITxyhL(ukd|*l;Nm$hdno7AWKjWX+un6w(DUWG^)vH(cTbN%jMUK1u+UpWE%i zuynQ|&I+}Zu{KF{~_ z{q=dC&-b}@ucySCU{3%5tJ~$Q1dwnmfkI1@eZvU=rQhu=s(Sf{--kat)CacWqRK2K zCAhxDbLe&D)%&CmYTfs<-7|k*?B8E=#>D+!WP=Sr24J8;Oxt@-FK01!o=clsV?2Wf zg>f;aM$%%wJ%jrq>A{K{Q=?1QHYp(SLKPI~=Ac3wlW;K;m`-3RfdgoPY9i1=;A0VR z02B^>rjm!F#}1MTJLW<20uXEX8PzaT4Lj8!YavK&FkLK>$p3;O57T{vHjp)(EE_Pe zYB*;yFdJG3(t^qpXeAIm1m4}EWedXc!+D_ z&oc51K7fY8L;DgjLe|4)+t4NXnlp?i%`>}@c3CI0x2k)slCRd{ozS*s$olyq@&|GrC;zn$tGzY*q($;= z*>wasq^B)=>!UX(<5=#h?d0$5$VkDSqjhggGi-XkH>-9w8MBtB;2qqkYoDHpXZnTe zhX^g}%=;_kVER|-eV=~P@dVb$|MHPnQuiiEzUMkCCNj4!mG!GbePdbY9!9Zov*gz1 zYW?J4I%oIhq#A6-{NJoOh>~7$L<=yUgfk(OHkMEc1`8zuGZFZTV@@DFoIMv^-5L>W zyi3TAv*5d#Y-FPfRJpn}BK5qjhhLj#BI_G%(7!H{D{6__MlaFZ;3BvgpCTf&K?|e2>mfp%Nd7$GN+<7O%`;j1y|(@ndoUPI~{nkJ7{A|9GZ)$b2cS6pt+X*OZIDh>_i*##rLU z_o=bM-NFu(2=7Fm;ayd6!th#!ovR7&uc1NeKjtY97~Vl)59Aysc3R_}e0;L0vj5|P zuj#NVYnw~p+k2yOctZLvZ`?lnS~8Um;VSEHrUnk)F2A_dFq3ys8pbIq=3wx z%g4H3Fl|GiB$P}*{pDwDTkpi+t+^I+}Zu{KF{~_ z{q=dC&-b}@ucySCU{3%5tJ~$Q1dwnmfkI1@eZvU=rQhu=s(Sf{--kat)CacWqRK2K zCAhxDbLe&D)%&CmYTfs<-7|k*?B8E=#>D+!WP=Sr24J8;Oxt@-FK01!o=clsV?2Wf zg>f;aM$%%wJ%jrq>A{K{Q=?1QHYp(SLKPI~=Ac3wlW;K;m`-3RfdgoPY9i1=;A0VR z02B^>rjm!F#}1MTJLW<20uXEX8PzaT4Lj8!YavK&FkLK>$p3;O57T{vHjp)(EE_Pe zYB*;yFdJG3(t^qpXeAIm1m4}EWedXc!+D_ z&oc51K7fY8L;DgjLe|4)+t4NXnlp?i%`>}@c3CI0x2k)slCRd{ozS*s$olyq@&|GrC;zn$tGzY*q($;= z*>wasq^B)=>!UX(<5=#h?d0$5$VkDSqjhggGi-XkH>-9w8MBtB;2qqkYoDHpXZnTe zhX^g}%=;_kVER|-eV=~P@dVb$|MHPnQuiiEzUMkCCNj4!mG!GbePdbY9!9Zov*gz1 zYW?J4I%oIhq#A6-{NJoOh>~7$L<=yUgfk(OHkMEc1`8zuGZFZTV@@DFoIMv^-5L>W zyi3TAv*5d#Y-FPfRJpn}BK5qjhhLj#BI_G%(7!H{D{6__MlaFZ;3BvgpCTf&K?|e2>mfp%Nd7$GN+<7O%`;j1y|(@ndoUPI~{nkJ7{A|9GZ)$b2cS6pt+X*OZIDh>_i*##rLU z_o=bM-NFu(2=7Fm;ayd6!th#!ovR7&uc1NeKjtY97~Vl)59Aysc3R_}e0;L0vj5|P zuj#NVYnw~p+k2yOctZLvZ`?lnS~8Um;VSEHrUnk)F2A_dFq3ys8pbIq=3wx z%g4H3Fl|GiB$P}*{pDwDTkpi+t+^('loading') async function check() { try { const s = await api.authState() + // 请求成功比浏览器的网络状态提示更可靠(局域网和网络切换时可能不同步)。 + online.value = true phase.value = !s.setUp ? 'setup' : s.authenticated ? 'ready' : 'login' } catch { phase.value = 'error' @@ -21,15 +25,19 @@ setUnauthorizedHandler(() => { stopWorkspace() phase.value = 'login' }) -onMounted(check) +onMounted(() => { + void check() + void registerPwa() +})