From 16f7cd54e3fdf80d10a0797e5455bf68bb9c0828 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Thu, 8 Jan 2026 16:14:30 +0000 Subject: [PATCH] appview: serve ico favicons on /favicon.ico Change-Id: lrpyxormllvpntqlzxumlnxtwlorrmmv this is recognized by safari. the only size this image is served at is 48x48. the dolly silhouette is used. cache/etag headers are identical to the svg output. Signed-off-by: oppiliappan --- appview/pages/templates/layouts/base.html | 3 + appview/state/favicon.go | 91 +++++++++++++++++++++++ appview/state/router.go | 4 +- appview/state/state.go | 13 ---- 4 files changed, 96 insertions(+), 15 deletions(-) create mode 100644 appview/state/favicon.go diff --git a/appview/pages/templates/layouts/base.html b/appview/pages/templates/layouts/base.html index f357201d..7dcd42c8 100644 --- a/appview/pages/templates/layouts/base.html +++ b/appview/pages/templates/layouts/base.html @@ -11,6 +11,9 @@ + + + diff --git a/appview/state/favicon.go b/appview/state/favicon.go new file mode 100644 index 00000000..c7f3e3f9 --- /dev/null +++ b/appview/state/favicon.go @@ -0,0 +1,91 @@ +package state + +import ( + "bytes" + "fmt" + "html/template" + "image" + "image/color" + "net/http" + + "github.com/srwiley/oksvg" + "github.com/srwiley/rasterx" + "golang.org/x/image/draw" + "tangled.org/core/appview/pages" + "tangled.org/core/ico" +) + +func (s *State) FaviconSvg(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "public, max-age=31536000") // one year + w.Header().Set("ETag", `"favicon-svg-v1"`) + + if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` { + w.WriteHeader(http.StatusNotModified) + return + } + + s.pages.Favicon(w) +} + +func (s *State) FaviconIco(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/x-icon") + w.Header().Set("Cache-Control", "public, max-age=31536000") // one year + w.Header().Set("ETag", `"favicon-ico-v1"`) + + if match := r.Header.Get("If-None-Match"); match == `"favicon-ico-v1"` { + w.WriteHeader(http.StatusNotModified) + return + } + + ico, err := dollyIco() + if err != nil { + s.logger.Error("failed to render ico", "err", err) + w.WriteHeader(http.StatusNotFound) + return + } + + w.Write(ico) +} + +func dollyIco() ([]byte, error) { + // first, get the bytes from the svg + tpl, err := template.New("dolly"). + ParseFS(pages.Files, "templates/fragments/dolly/silhouette.html") + if err != nil { + return nil, err + } + + var svgData bytes.Buffer + if err := tpl.ExecuteTemplate(&svgData, "fragments/dolly/silhouette", "#000000"); err != nil { + return nil, err + } + + img, err := svgToImage(svgData.Bytes(), 48, 48) + if err != nil { + return nil, err + } + + ico, err := ico.ImageToIco(img) + if err != nil { + return nil, err + } + + return ico, nil +} + +func svgToImage(svgData []byte, w, h int) (image.Image, error) { + icon, err := oksvg.ReadIconStream(bytes.NewReader(svgData)) + if err != nil { + return nil, fmt.Errorf("error parsing SVG: %v", err) + } + + icon.SetTarget(0, 0, float64(w), float64(h)) + rgba := image.NewRGBA(image.Rect(0, 0, w, h)) + draw.Draw(rgba, rgba.Bounds(), &image.Uniform{color.Transparent}, image.Point{}, draw.Src) + scanner := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds()) + raster := rasterx.NewDasher(w, h, scanner) + icon.Draw(raster, 1.0) + + return rgba, nil +} diff --git a/appview/state/router.go b/appview/state/router.go index 6b14e647..5feb7780 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -32,8 +32,8 @@ func (s *State) Router() http.Handler { s.pages, ) - router.Get("/favicon.svg", s.Favicon) - router.Get("/favicon.ico", s.Favicon) + router.Get("/favicon.svg", s.FaviconSvg) + router.Get("/favicon.ico", s.FaviconIco) router.Get("/pwa-manifest.json", s.PWAManifest) router.Get("/robots.txt", s.RobotsTxt) diff --git a/appview/state/state.go b/appview/state/state.go index 11d94a5d..ab1f1ae4 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -202,19 +202,6 @@ func (s *State) Close() error { return s.db.Close() } -func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "image/svg+xml") - w.Header().Set("Cache-Control", "public, max-age=31536000") // one year - w.Header().Set("ETag", `"favicon-svg-v1"`) - - if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` { - w.WriteHeader(http.StatusNotModified) - return - } - - s.pages.Favicon(w) -} - func (s *State) RobotsTxt(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.Header().Set("Cache-Control", "public, max-age=86400") // one day -- 2.43.0