diff --git a/internal/config/config_app.go b/internal/config/config_app.go index 13d110fdb..a6e1a01e2 100644 --- a/internal/config/config_app.go +++ b/internal/config/config_app.go @@ -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, diff --git a/internal/config/pwa/config.go b/internal/config/pwa/config.go index a46f2893f..1f6dadb29 100644 --- a/internal/config/pwa/config.go +++ b/internal/config/pwa/config.go @@ -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"` diff --git a/internal/config/pwa/icon.go b/internal/config/pwa/icon.go index 5e6a85b9f..30c0c981c 100644 --- a/internal/config/pwa/icon.go +++ b/internal/config/pwa/icon.go @@ -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) +} diff --git a/internal/config/pwa/icon_test.go b/internal/config/pwa/icon_test.go index e24b7010b..c866a0a1a 100644 --- a/internal/config/pwa/icon_test.go +++ b/internal/config/pwa/icon_test.go @@ -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 +} diff --git a/internal/config/pwa/manifest_test.go b/internal/config/pwa/manifest_test.go index 884288463..bab8f7950 100644 --- a/internal/config/pwa/manifest_test.go +++ b/internal/config/pwa/manifest_test.go @@ -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) { diff --git a/internal/config/pwa/testdata/icons/partial/maskable/192.png b/internal/config/pwa/testdata/icons/partial/maskable/192.png new file mode 100644 index 000000000..d377ae492 Binary files /dev/null and b/internal/config/pwa/testdata/icons/partial/maskable/192.png differ diff --git a/internal/config/pwa/testdata/icons/test/maskable/192.png b/internal/config/pwa/testdata/icons/test/maskable/192.png new file mode 100644 index 000000000..d377ae492 Binary files /dev/null and b/internal/config/pwa/testdata/icons/test/maskable/192.png differ diff --git a/internal/config/pwa/testdata/icons/test/maskable/512.png b/internal/config/pwa/testdata/icons/test/maskable/512.png new file mode 100644 index 000000000..9c625a4af Binary files /dev/null and b/internal/config/pwa/testdata/icons/test/maskable/512.png differ