PWA: Add maskable icons, lang/dir, and install screenshots #5696
BIN
assets/static/icons/app/maskable/192.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
assets/static/icons/app/maskable/512.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
assets/static/icons/bloom/maskable/192.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
assets/static/icons/bloom/maskable/512.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
assets/static/icons/bold/maskable/192.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
assets/static/icons/bold/maskable/512.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
assets/static/icons/crisp/maskable/192.png
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
assets/static/icons/crisp/maskable/512.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
assets/static/icons/flower/maskable/192.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
assets/static/icons/flower/maskable/512.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
assets/static/icons/logo/maskable/192.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
BIN
assets/static/icons/logo/maskable/512.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
assets/static/icons/mint/maskable/192.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
assets/static/icons/mint/maskable/512.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
assets/static/icons/ring/maskable/192.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
assets/static/icons/ring/maskable/512.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
assets/static/icons/shutter/maskable/192.png
Normal file
|
After Width: | Height: | Size: 8 KiB |
BIN
assets/static/icons/shutter/maskable/512.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
assets/static/icons/square/maskable/192.png
Normal file
|
After Width: | Height: | Size: 8 KiB |
BIN
assets/static/icons/square/maskable/512.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
assets/static/img/screenshots/narrow.jpg
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
assets/static/img/screenshots/wide.jpg
Normal file
|
After Width: | Height: | Size: 132 KiB |
|
|
@ -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))
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
35
internal/config/pwa/screenshot.go
Normal 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",
|
||||
},
|
||||
}
|
||||
}
|
||||
22
internal/config/pwa/screenshot_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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."
|
||||