PWA: Add maskable icons, lang/dir, and install screenshots #5696

This commit is contained in:
Ömer Duran 2026-06-30 17:53:52 +00:00 committed by GitHub
parent 25b99b3ec4
commit 4b33d3bd1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 236 additions and 18 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

View file

@ -126,9 +126,12 @@ func TestConfig_AppManifest(t *testing.T) {
assert.Equal(t, appConf.Description, result.Description)
assert.Equal(t, appConf.BaseUri, result.Scope)
assert.Equal(t, pwa.StartUrl(appConf.BaseUri, appConf.FrontendUri), result.StartUrl)
assert.Len(t, result.Icons, len(pwa.IconSizes))
assert.Len(t, result.Icons, len(pwa.IconSizes)+len(pwa.MaskableIconSizes))
assert.Len(t, result.Categories, len(pwa.Categories))
assert.Len(t, result.Permissions, len(pwa.Permissions))
assert.NotEmpty(t, result.Lang)
assert.NotEmpty(t, result.Dir)
assert.Len(t, result.Screenshots, 2)
cached := c.AppManifest()
assert.NotEmpty(t, cached)
@ -137,7 +140,7 @@ func TestConfig_AppManifest(t *testing.T) {
assert.Equal(t, appConf.Description, cached.Description)
assert.Equal(t, appConf.BaseUri, cached.Scope)
assert.Equal(t, pwa.StartUrl(appConf.BaseUri, appConf.FrontendUri), cached.StartUrl)
assert.Len(t, cached.Icons, len(pwa.IconSizes))
assert.Len(t, cached.Icons, len(pwa.IconSizes)+len(pwa.MaskableIconSizes))
assert.Len(t, cached.Categories, len(pwa.Categories))
assert.Len(t, cached.Permissions, len(pwa.Permissions))
})

View file

@ -15,14 +15,18 @@ type Icons []Icon
// Icon represents an app icon.
type Icon struct {
Src string `json:"src"`
Sizes string `json:"sizes,omitempty"`
Type string `json:"type,omitempty"`
Src string `json:"src"`
Sizes string `json:"sizes,omitempty"`
Type string `json:"type,omitempty"`
Purpose string `json:"purpose,omitempty"`
}
// IconSizes represents standard app icon sizes.
var IconSizes = []int{16, 32, 76, 114, 128, 144, 152, 160, 167, 180, 192, 196, 256, 400, 512}
// MaskableIconSizes represents the safe-zone-padded icon sizes emitted with purpose "maskable".
var MaskableIconSizes = []int{192, 512}
// NewIcons creates new app icons in the default sizes based on the parameters provided.
func NewIcons(c Config) Icons {
staticUri := c.StaticUri
@ -73,14 +77,25 @@ func NewIcons(c Config) Icons {
}}
}
icons := make(Icons, len(IconSizes))
icons := make(Icons, 0, len(IconSizes)+len(MaskableIconSizes))
for i, d := range IconSizes {
icons[i] = Icon{
for _, d := range IconSizes {
icons = append(icons, Icon{
Src: fmt.Sprintf("%s/icons/%s/%d.png", staticUri, appIcon, d),
Sizes: fmt.Sprintf("%dx%d", d, d),
Type: "image/png",
}
})
}
// Adaptive launcher icons generated from a safe-zone-padded source so Android
// fills the home-screen mask shape instead of letterboxing the default icon.
for _, d := range MaskableIconSizes {
icons = append(icons, Icon{
Src: fmt.Sprintf("%s/icons/%s/maskable/%d.png", staticUri, appIcon, d),
Sizes: fmt.Sprintf("%dx%d", d, d),
Type: "image/png",
Purpose: "maskable",
})
}
return icons

View file

@ -13,24 +13,46 @@ func TestNewIcons(t *testing.T) {
c := Config{StaticUri: "https://demo-cdn.photoprism.app/static", Icon: "test"}
result := NewIcons(c)
assert.NotEmpty(t, result)
assert.Len(t, result, len(IconSizes)+len(MaskableIconSizes))
assert.Equal(t, "https://demo-cdn.photoprism.app/static/icons/test/16.png", result[0].Src)
assert.Equal(t, "image/png", result[0].Type)
assert.Equal(t, "16x16", result[0].Sizes)
assert.Equal(t, "", result[0].Purpose)
})
t.Run("Maskable", func(t *testing.T) {
c := Config{StaticUri: "https://demo-cdn.photoprism.app/static", Icon: "test"}
result := NewIcons(c)
maskable := make(Icons, 0, len(MaskableIconSizes))
for _, icon := range result {
if icon.Purpose == "maskable" {
maskable = append(maskable, icon)
}
}
assert.Len(t, maskable, len(MaskableIconSizes))
assert.Equal(t, "https://demo-cdn.photoprism.app/static/icons/test/maskable/192.png", maskable[0].Src)
assert.Equal(t, "192x192", maskable[0].Sizes)
assert.Equal(t, "image/png", maskable[0].Type)
assert.Equal(t, "https://demo-cdn.photoprism.app/static/icons/test/maskable/512.png", maskable[1].Src)
assert.Equal(t, "512x512", maskable[1].Sizes)
})
t.Run("Custom", func(t *testing.T) {
c := Config{StaticUri: "https://demo-cdn.photoprism.app/static", Icon: "/test.png"}
result := NewIcons(c)
assert.NotEmpty(t, result)
assert.Len(t, result, 1)
assert.Equal(t, "/test.png", result[0].Src)
assert.Equal(t, "image/png", result[0].Type)
assert.Equal(t, "", result[0].Sizes)
assert.Equal(t, "", result[0].Purpose)
})
t.Run("Theme", func(t *testing.T) {
c := Config{StaticUri: "https://demo-cdn.photoprism.app/static", Icon: "/_theme/example.png", ThemePath: fs.Abs("./testdata"), ThemeUri: "/_theme"}
result := NewIcons(c)
assert.NotEmpty(t, result)
assert.Len(t, result, 1)
assert.Equal(t, "/_theme/example.png", result[0].Src)
assert.Equal(t, "image/png", result[0].Type)
assert.Equal(t, "100x67", result[0].Sizes)
assert.Equal(t, "", result[0].Purpose)
})
}

View file

@ -14,6 +14,8 @@ type Manifest struct {
Name string `json:"name"`
ShortName string `json:"short_name,omitempty"`
Description string `json:"description,omitempty"`
Lang string `json:"lang,omitempty"`
Dir string `json:"dir,omitempty"`
Categories list.List `json:"categories"`
Developer Url `json:"developer"`
DisplayOverride []string `json:"display_override"`
@ -30,6 +32,7 @@ type Manifest struct {
OptionalPermissions list.List `json:"optional_permissions"`
HostPermissions []string `json:"host_permissions"`
Icons Icons `json:"icons"`
Screenshots Screenshots `json:"screenshots,omitempty"`
}
// NewManifest creates a new progressive web app manifest based on the config provided.
@ -40,6 +43,8 @@ func NewManifest(c Config) (m *Manifest) {
Name: c.Name,
ShortName: txt.Clip(c.Name, 32),
Description: c.Description,
Lang: clean.WebLocale(c.DefaultLocale, "en"),
Dir: clean.TextDir(c.DefaultLocale, "en"),
Categories: Categories,
Developer: PhotoPrism,
DisplayOverride: DisplayOverride,
@ -60,5 +65,6 @@ func NewManifest(c Config) (m *Manifest) {
OptionalPermissions: OptionalPermissions,
HostPermissions: HostPermissions(c.SiteUrl, c.CdnUrl),
Icons: NewIcons(c),
Screenshots: NewScreenshots(c),
}
}

View file

@ -8,14 +8,15 @@ import (
func TestNewManifest(t *testing.T) {
c := Config{
Icon: "logo",
Color: "#aaaaaa",
Name: "TestPrism+",
Description: "App's Description",
Mode: "fullscreen",
BaseUri: "/",
FrontendUri: "/library",
StaticUri: "/static",
Icon: "logo",
Color: "#aaaaaa",
Name: "TestPrism+",
Description: "App's Description",
DefaultLocale: "pt_BR",
Mode: "fullscreen",
BaseUri: "/",
FrontendUri: "/library",
StaticUri: "/static",
}
t.Run("Standard", func(t *testing.T) {
@ -24,13 +25,25 @@ func TestNewManifest(t *testing.T) {
assert.Equal(t, c.Name, result.Name)
assert.Equal(t, c.Name, result.ShortName)
assert.Equal(t, c.Description, result.Description)
assert.Equal(t, "pt-BR", result.Lang)
assert.Equal(t, "ltr", result.Dir)
assert.Equal(t, c.BaseUri, result.Scope)
assert.Equal(t, StartUrl(c.BaseUri, c.FrontendUri), result.StartUrl)
assert.Equal(t, "library/browse", result.Shortcuts[0].Url)
assert.Len(t, result.Icons, len(IconSizes))
assert.Len(t, result.Icons, len(IconSizes)+len(MaskableIconSizes))
assert.Len(t, result.Screenshots, 2)
assert.Equal(t, "wide", result.Screenshots[0].FormFactor)
assert.Equal(t, "narrow", result.Screenshots[1].FormFactor)
assert.Len(t, result.Categories, len(Categories))
assert.Len(t, result.Permissions, len(Permissions))
})
t.Run("DefaultLang", func(t *testing.T) {
d := c
d.DefaultLocale = ""
result := NewManifest(d)
assert.Equal(t, "en", result.Lang)
assert.Equal(t, "ltr", result.Dir)
})
t.Run("StartUrlForPathScope", func(t *testing.T) {
c.BaseUri = "/instance/pro-1/"
c.FrontendUri = "/instance/pro-1/library"

View file

@ -0,0 +1,35 @@
package pwa
import "fmt"
// Screenshots represents a list of app store style install-prompt screenshots.
type Screenshots []Screenshot
// Screenshot represents a single install-prompt screenshot.
type Screenshot struct {
Src string `json:"src"`
Sizes string `json:"sizes,omitempty"`
Type string `json:"type,omitempty"`
FormFactor string `json:"form_factor,omitempty"`
}
// NewScreenshots creates the install-prompt screenshots based on the config provided.
// Browsers show a richer, app-store-style install dialog when wide and narrow form factors are present.
func NewScreenshots(c Config) Screenshots {
staticUri := c.StaticUri
return Screenshots{
{
Src: fmt.Sprintf("%s/img/screenshots/wide.jpg", staticUri),
Sizes: "1280x900",
Type: "image/jpeg",
FormFactor: "wide",
},
{
Src: fmt.Sprintf("%s/img/screenshots/narrow.jpg", staticUri),
Sizes: "375x667",
Type: "image/jpeg",
FormFactor: "narrow",
},
}
}

View file

@ -0,0 +1,22 @@
package pwa
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewScreenshots(t *testing.T) {
t.Run("Standard", func(t *testing.T) {
c := Config{StaticUri: "https://demo-cdn.photoprism.app/static"}
result := NewScreenshots(c)
assert.Len(t, result, 2)
assert.Equal(t, "https://demo-cdn.photoprism.app/static/img/screenshots/wide.jpg", result[0].Src)
assert.Equal(t, "1280x900", result[0].Sizes)
assert.Equal(t, "image/jpeg", result[0].Type)
assert.Equal(t, "wide", result[0].FormFactor)
assert.Equal(t, "https://demo-cdn.photoprism.app/static/img/screenshots/narrow.jpg", result[1].Src)
assert.Equal(t, "375x667", result[1].Sizes)
assert.Equal(t, "narrow", result[1].FormFactor)
})
}

View file

@ -321,6 +321,10 @@ func TestWebAppRoutes(t *testing.T) {
assert.True(t, strings.Contains(manifest, `"start_url": "`+pwa.StartUrl(conf.BaseUri("/"), conf.FrontendUri(``))+`",`))
assert.True(t, strings.Contains(manifest, `"url": "library/browse"`))
assert.True(t, strings.Contains(manifest, "/static/icons/logo/128.png"))
assert.True(t, strings.Contains(manifest, `"purpose": "maskable"`))
assert.True(t, strings.Contains(manifest, "/static/icons/logo/maskable/512.png"))
assert.True(t, strings.Contains(manifest, `"lang":`))
assert.True(t, strings.Contains(manifest, `"form_factor": "wide"`))
})
t.Run("GetServiceWorker", func(t *testing.T) {
w := httptest.NewRecorder()

View file

@ -43,3 +43,28 @@ func WebLocale(locale, defaultLocale string) string {
return defaultLocale
}
// rtlLocales contains the ISO 639 primary language subtags rendered right-to-left.
var rtlLocales = map[string]bool{
"ar": true, // Arabic
"fa": true, // Persian
"he": true, // Hebrew
"ku": true, // Kurdish (Sorani)
}
// TextDir returns the user interface text direction ("rtl" or "ltr") for the specified locale,
// falling back to defaultLocale when locale is empty or invalid, and to "ltr" otherwise.
func TextDir(locale, defaultLocale string) string {
for _, l := range []string{locale, defaultLocale} {
// WebLocale normalizes the language subtag to lowercase, so no extra ToLower is needed.
if l = WebLocale(l, ""); l == "" {
continue
} else if lang, _, _ := strings.Cut(l, "-"); rtlLocales[lang] {
return "rtl"
} else {
return "ltr"
}
}
return "ltr"
}

View file

@ -94,3 +94,24 @@ func TestWebLocale(t *testing.T) {
assert.Equal(t, "und", WebLocale("cs_CZX", "und"))
})
}
func TestTextDir(t *testing.T) {
t.Run("LeftToRight", func(t *testing.T) {
assert.Equal(t, "ltr", TextDir("en", "en"))
assert.Equal(t, "ltr", TextDir("de", "en"))
assert.Equal(t, "ltr", TextDir("pt_BR", "en"))
assert.Equal(t, "ltr", TextDir("zh_TW", "en"))
})
t.Run("RightToLeft", func(t *testing.T) {
assert.Equal(t, "rtl", TextDir("ar", "en"))
assert.Equal(t, "rtl", TextDir("he", "en"))
assert.Equal(t, "rtl", TextDir("fa-IR", "en"))
assert.Equal(t, "rtl", TextDir("KU", "en"))
})
t.Run("Fallback", func(t *testing.T) {
assert.Equal(t, "ltr", TextDir("", "en"))
assert.Equal(t, "rtl", TextDir("", "ar"))
assert.Equal(t, "ltr", TextDir("und", "de"))
assert.Equal(t, "ltr", TextDir("", ""))
})
}

52
scripts/render/maskable-icon.sh Executable file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Renders safe-zone-padded "maskable" PWA icons from the theme icon SVGs, so Android fills the
# home-screen adaptive-icon mask shape instead of letterboxing the default icon. The logo is
# rendered at 80% of the target size (10% safe-zone padding per side) and centered on an opaque
# background. See https://web.dev/articles/maskable-icon for the safe-zone specification.
if [[ -n $1 ]] && [[ $1 == "-h" || $1 == "--help" ]]; then
echo "Usage: (1) ${0##*/} (renders maskable icons for all assets/static/icons/*.svg themes)" 1>&2
echo " (2) ${0##*/} [name] (renders maskable icons for assets/static/icons/[name].svg only)" 1>&2
exit 1
fi
set -e
sizes=(192 512)
safe_zone=80 # percentage of the icon that holds the visible logo (the rest is mask-safe padding)
background="white"
# Collect the theme SVG sources to render.
sources=()
if [[ -n $1 ]]; then
sources=("assets/static/icons/${1}.svg")
else
for svg in assets/static/icons/*.svg; do
sources+=("$svg")
done
fi
for svg in "${sources[@]}"; do
if [ ! -f "$svg" ]; then
echo "$svg not found" 1>&2
exit 1
fi
name="$(basename "$svg" .svg)"
dest="assets/static/icons/${name}/maskable"
mkdir -p "$dest"
echo "Creating maskable icons from ${svg}..."
for size in "${sizes[@]}"; do
inner=$(( size * safe_zone / 100 ))
tmp="$(mktemp --suffix=.png)"
rsvg-convert -a -w "$inner" -h "$inner" "$svg" > "$tmp"
magick -size "${size}x${size}" "xc:${background}" "$tmp" -gravity center -composite -depth 8 -strip "$dest/$size.png"
rm -f "$tmp"
echo "$dest/$size.png"
done
done
echo "Done."