PWA: Only emit maskable icons when the variant exists #5696

This commit is contained in:
Michael Mayer 2026-07-06 10:58:16 +00:00
parent 4b33d3bd1a
commit b7e758c607
8 changed files with 76 additions and 9 deletions

View file

@ -98,6 +98,7 @@ func (c *Config) AppConfig() pwa.Config {
BaseUri: c.BaseUri("/"),
FrontendUri: c.FrontendUri(""),
StaticUri: c.StaticUri(),
StaticPath: c.StaticPath(),
SiteUrl: c.SiteUrl(),
CdnUrl: c.CdnUrl("/"),
ThemeUri: ThemeUri,

View file

@ -11,6 +11,7 @@ type Config struct {
BaseUri string `json:"baseUri"`
FrontendUri string `json:"frontendUri"`
StaticUri string `json:"staticUri"`
StaticPath string `json:"staticPath"`
SiteUrl string `json:"siteUrl"`
CdnUrl string `json:"cdnUrl"`
ThemeUri string `json:"themeUri"`

View file

@ -89,7 +89,14 @@ func NewIcons(c Config) Icons {
// Adaptive launcher icons generated from a safe-zone-padded source so Android
// fills the home-screen mask shape instead of letterboxing the default icon.
// Emitted only for sizes whose file exists on disk so the manifest never
// advertises a maskable variant that resolves to a 404, e.g. for custom icon
// sets without pre-rendered maskable images.
for _, d := range MaskableIconSizes {
if !maskableIconExists(c.StaticPath, appIcon, d) {
continue
}
icons = append(icons, Icon{
Src: fmt.Sprintf("%s/icons/%s/maskable/%d.png", staticUri, appIcon, d),
Sizes: fmt.Sprintf("%dx%d", d, d),
@ -100,3 +107,16 @@ func NewIcons(c Config) Icons {
return icons
}
// maskableIconExists reports whether a non-empty maskable icon file of the given
// size exists on disk for the specified icon set. It returns false when staticPath
// is unset, since the manifest must not reference variants that cannot be verified.
func maskableIconExists(staticPath, appIcon string, size int) bool {
if staticPath == "" {
return false
}
fileName := filepath.Join(staticPath, fs.IconsDir, appIcon, "maskable", fmt.Sprintf("%d.png", size))
return fs.FileExistsNotEmpty(fileName)
}

View file

@ -10,7 +10,7 @@ import (
func TestNewIcons(t *testing.T) {
t.Run("Standard", func(t *testing.T) {
c := Config{StaticUri: "https://demo-cdn.photoprism.app/static", Icon: "test"}
c := Config{StaticUri: "https://demo-cdn.photoprism.app/static", StaticPath: fs.Abs("./testdata"), Icon: "test"}
result := NewIcons(c)
assert.NotEmpty(t, result)
assert.Len(t, result, len(IconSizes)+len(MaskableIconSizes))
@ -20,14 +20,9 @@ func TestNewIcons(t *testing.T) {
assert.Equal(t, "", result[0].Purpose)
})
t.Run("Maskable", func(t *testing.T) {
c := Config{StaticUri: "https://demo-cdn.photoprism.app/static", Icon: "test"}
c := Config{StaticUri: "https://demo-cdn.photoprism.app/static", StaticPath: fs.Abs("./testdata"), Icon: "test"}
result := NewIcons(c)
maskable := make(Icons, 0, len(MaskableIconSizes))
for _, icon := range result {
if icon.Purpose == "maskable" {
maskable = append(maskable, icon)
}
}
maskable := maskableIcons(result)
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)
@ -35,6 +30,26 @@ func TestNewIcons(t *testing.T) {
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("MaskablePartial", func(t *testing.T) {
c := Config{StaticUri: "https://demo-cdn.photoprism.app/static", StaticPath: fs.Abs("./testdata"), Icon: "partial"}
result := NewIcons(c)
maskable := maskableIcons(result)
assert.Len(t, maskable, 1)
assert.Equal(t, "https://demo-cdn.photoprism.app/static/icons/partial/maskable/192.png", maskable[0].Src)
assert.Len(t, result, len(IconSizes)+1)
})
t.Run("MaskableMissing", func(t *testing.T) {
c := Config{StaticUri: "https://demo-cdn.photoprism.app/static", StaticPath: fs.Abs("./testdata"), Icon: "nomask"}
result := NewIcons(c)
assert.Empty(t, maskableIcons(result))
assert.Len(t, result, len(IconSizes))
})
t.Run("StaticPathUnset", func(t *testing.T) {
c := Config{StaticUri: "https://demo-cdn.photoprism.app/static", Icon: "test"}
result := NewIcons(c)
assert.Empty(t, maskableIcons(result))
assert.Len(t, result, len(IconSizes))
})
t.Run("Custom", func(t *testing.T) {
c := Config{StaticUri: "https://demo-cdn.photoprism.app/static", Icon: "/test.png"}
result := NewIcons(c)
@ -56,3 +71,30 @@ func TestNewIcons(t *testing.T) {
assert.Equal(t, "", result[0].Purpose)
})
}
func TestMaskableIconExists(t *testing.T) {
staticPath := fs.Abs("./testdata")
t.Run("Exists", func(t *testing.T) {
assert.True(t, maskableIconExists(staticPath, "test", 192))
assert.True(t, maskableIconExists(staticPath, "test", 512))
})
t.Run("Missing", func(t *testing.T) {
assert.False(t, maskableIconExists(staticPath, "test", 256))
assert.False(t, maskableIconExists(staticPath, "partial", 512))
assert.False(t, maskableIconExists(staticPath, "nomask", 192))
})
t.Run("StaticPathUnset", func(t *testing.T) {
assert.False(t, maskableIconExists("", "test", 192))
})
}
// maskableIcons returns the subset of icons emitted with purpose "maskable".
func maskableIcons(icons Icons) Icons {
maskable := make(Icons, 0, len(MaskableIconSizes))
for _, icon := range icons {
if icon.Purpose == "maskable" {
maskable = append(maskable, icon)
}
}
return maskable
}

View file

@ -4,11 +4,13 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestNewManifest(t *testing.T) {
c := Config{
Icon: "logo",
Icon: "test",
Color: "#aaaaaa",
Name: "TestPrism+",
Description: "App's Description",
@ -17,6 +19,7 @@ func TestNewManifest(t *testing.T) {
BaseUri: "/",
FrontendUri: "/library",
StaticUri: "/static",
StaticPath: fs.Abs("./testdata"),
}
t.Run("Standard", func(t *testing.T) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB