From 25c9ef363db0e4c9f2484a5fd3677c273ee84495 Mon Sep 17 00:00:00 2001 From: afterlifepro Date: Fri, 3 Oct 2025 16:06:59 +0100 Subject: [PATCH] appview/state, nix/pkgs, /: generate png icons and add to pwa manifest this is required since firefox apparently doesn't support png icons (in my testing) Signed-off-by: afterlifepro --- appview/state/router.go | 1 + appview/state/state.go | 125 ++++++++++++++++++++++++++---- nix/pkgs/appview-static-files.nix | 1 + 3 files changed, 112 insertions(+), 15 deletions(-) diff --git a/appview/state/router.go b/appview/state/router.go index fd2c4e84..449cb22f 100644 --- a/appview/state/router.go +++ b/appview/state/router.go @@ -35,6 +35,7 @@ func (s *State) Router() http.Handler { router.Get("/favicon.svg", s.Favicon) router.Get("/favicon.ico", s.Favicon) router.Get("/pwa-manifest.json", s.PWAManifest) + router.Get("/pwa-icon.png", s.PWAIcon) router.Get("/robots.txt", s.RobotsTxt) userRouter := s.UserRouter(&middleware) diff --git a/appview/state/state.go b/appview/state/state.go index ce632747..07b0247c 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -1,12 +1,18 @@ package state import ( + "bytes" "context" "database/sql" "errors" "fmt" + "image" + "image/color" + "image/draw" + "image/png" "log/slog" "net/http" + "strconv" "strings" "time" @@ -20,6 +26,7 @@ import ( dbnotify "tangled.org/core/appview/notify/db" phnotify "tangled.org/core/appview/notify/posthog" "tangled.org/core/appview/oauth" + "tangled.org/core/appview/ogcard" "tangled.org/core/appview/pages" "tangled.org/core/appview/reporesolver" "tangled.org/core/appview/validator" @@ -39,6 +46,7 @@ import ( securejoin "github.com/cyphar/filepath-securejoin" "github.com/go-chi/chi/v5" "github.com/posthog/posthog-go" + "github.com/srwiley/rasterx" ) type State struct { @@ -221,27 +229,114 @@ Allow: / // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest const manifestJson = `{ - "name": "tangled", - "description": "tightly-knit social coding.", - "icons": [ - { - "src": "/favicon.svg", - "sizes": "144x144" - } - ], - "start_url": "/", - "id": "org.tangled", - - "display": "standalone", - "background_color": "#111827", - "theme_color": "#111827" + "name": "tangled", + "description": "tightly-knit social coding.", + "start_url": "/", + "id": "org.tangled", + "display": "standalone", + "background_color": "#111827", + "theme_color": "#111827", + "icons": [ + { + "src": "/pwa-icon.png?res=512&transparent=true", + "type": "image/png", + "sizes": "512x512", + "purpose": "any monchrome" + }, + { + "src": "/pwa-icon.png?res=512", + "type": "image/png", + "sizes": "512x512", + "purpose": "maskable" + }, + { + "src": "/pwa-icon.png?res=192", + "type": "image/png", + "sizes": "192x192", + "purpose": "maskable" + }, + { + "src": "/pwa-icon.png?res=144", + "type": "image/png", + "sizes": "144x144", + "purpose": "maskable" + }, + { + "src": "/pwa-icon.png?res=96", + "type": "image/png", + "sizes": "96x96", + "purpose": "maskable" + }, + { + "src": "/pwa-icon.png?res=72", + "type": "image/png", + "sizes": "72x72", + "purpose": "maskable" + }, + { + "src": "/pwa-icon.png?res=48", + "type": "image/png", + "sizes": "48x48", + "purpose": "maskable" + } + ] }` -func (p *State) PWAManifest(w http.ResponseWriter, r *http.Request) { +func (s *State) PWAManifest(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write([]byte(manifestJson)) } + +func (s *State) PWAIcon(w http.ResponseWriter, r *http.Request) { + tangledBgColour := color.RGBA{0x11, 0x18, 0x27, 0xff} + etag := "W/\"dolly-logo-v1 " + r.URL.Query().Get("res") + r.URL.Query().Get("transparent") + "\"" + + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "public, max-age=604800, stale-while-revalidate=86400, stale-if-error=86400") + w.Header().Set("Etag", etag) + // if the client already has the same logo dont bother recreating it + if len(r.Header["If-None-Match"]) == 1 && r.Header["If-None-Match"][0] == etag { + w.WriteHeader(304) + return + } + + icon, err := ogcard.BuildSVGIconFromPath("templates/fragments/dolly/logo.svg", tangledBgColour) + // ignore error as icon is default icon on error + // also this should not fail + + transparent := r.URL.Query().Get("transparent") == "true"; + size, err := strconv.Atoi(r.URL.Query().Get("res")) + if err != nil { + size = 512 + } + + // maskable safe area is centered circle with radius 40% + // area outside may be cropped so make sure the icon fills it + // bc trig, safe square is just over 70% of screen + icon.SetTarget(float64(size) * 0.15, float64(size) * 0.15, float64(size) * 0.7, float64(size) * 0.7) + rgba := image.NewRGBA(image.Rect(0, 0, size, size)) + + // set image bg + var bgColour color.Color = tangledBgColour + if transparent { + bgColour = color.Transparent + } + draw.Draw(rgba, rgba.Bounds(), &image.Uniform{ bgColour }, image.Point{}, draw.Src) + + // Create a scanner and rasterize the SVG + scanner := rasterx.NewScannerGV(size, size, rgba, rgba.Bounds()) + raster := rasterx.NewDasher(size, size, scanner) + + icon.Draw(raster, 1.0) + + var img_buff bytes.Buffer + err = png.Encode(&img_buff, rgba) + // ignore error as encoding shouldnt fail + // if it fails itll return no bytes which is fine + w.Write(img_buff.Bytes()) +} + func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { user := s.oauth.GetUser(r) s.pages.TermsOfService(w, pages.TermsOfServiceParams{ diff --git a/nix/pkgs/appview-static-files.nix b/nix/pkgs/appview-static-files.nix index 623c4825..d2ddeef6 100644 --- a/nix/pkgs/appview-static-files.nix +++ b/nix/pkgs/appview-static-files.nix @@ -17,6 +17,7 @@ runCommandLocal "appview-static-files" { (allow file-read* (subpath "/System/Library/OpenSSL")) ''; } '' + #!/bin/bash mkdir -p $out/{fonts,icons} && cd $out cp -f ${htmx-src} htmx.min.js cp -f ${htmx-ws-src} htmx-ext-ws.min.js -- 2.52.0