diff --git a/assets/static/icons/app/maskable/192.png b/assets/static/icons/app/maskable/192.png new file mode 100644 index 000000000..1281bb4c8 Binary files /dev/null and b/assets/static/icons/app/maskable/192.png differ diff --git a/assets/static/icons/app/maskable/512.png b/assets/static/icons/app/maskable/512.png new file mode 100644 index 000000000..7b0736060 Binary files /dev/null and b/assets/static/icons/app/maskable/512.png differ diff --git a/assets/static/icons/bloom/maskable/192.png b/assets/static/icons/bloom/maskable/192.png new file mode 100644 index 000000000..8509e68b1 Binary files /dev/null and b/assets/static/icons/bloom/maskable/192.png differ diff --git a/assets/static/icons/bloom/maskable/512.png b/assets/static/icons/bloom/maskable/512.png new file mode 100644 index 000000000..0fa8807ba Binary files /dev/null and b/assets/static/icons/bloom/maskable/512.png differ diff --git a/assets/static/icons/bold/maskable/192.png b/assets/static/icons/bold/maskable/192.png new file mode 100644 index 000000000..caccc9108 Binary files /dev/null and b/assets/static/icons/bold/maskable/192.png differ diff --git a/assets/static/icons/bold/maskable/512.png b/assets/static/icons/bold/maskable/512.png new file mode 100644 index 000000000..1bc801990 Binary files /dev/null and b/assets/static/icons/bold/maskable/512.png differ diff --git a/assets/static/icons/crisp/maskable/192.png b/assets/static/icons/crisp/maskable/192.png new file mode 100644 index 000000000..0760a5cdd Binary files /dev/null and b/assets/static/icons/crisp/maskable/192.png differ diff --git a/assets/static/icons/crisp/maskable/512.png b/assets/static/icons/crisp/maskable/512.png new file mode 100644 index 000000000..32c5e59a8 Binary files /dev/null and b/assets/static/icons/crisp/maskable/512.png differ diff --git a/assets/static/icons/flower/maskable/192.png b/assets/static/icons/flower/maskable/192.png new file mode 100644 index 000000000..d93f44e23 Binary files /dev/null and b/assets/static/icons/flower/maskable/192.png differ diff --git a/assets/static/icons/flower/maskable/512.png b/assets/static/icons/flower/maskable/512.png new file mode 100644 index 000000000..dc45a123a Binary files /dev/null and b/assets/static/icons/flower/maskable/512.png differ diff --git a/assets/static/icons/logo/maskable/192.png b/assets/static/icons/logo/maskable/192.png new file mode 100644 index 000000000..d377ae492 Binary files /dev/null and b/assets/static/icons/logo/maskable/192.png differ diff --git a/assets/static/icons/logo/maskable/512.png b/assets/static/icons/logo/maskable/512.png new file mode 100644 index 000000000..9c625a4af Binary files /dev/null and b/assets/static/icons/logo/maskable/512.png differ diff --git a/assets/static/icons/mint/maskable/192.png b/assets/static/icons/mint/maskable/192.png new file mode 100644 index 000000000..4f6ddc3d6 Binary files /dev/null and b/assets/static/icons/mint/maskable/192.png differ diff --git a/assets/static/icons/mint/maskable/512.png b/assets/static/icons/mint/maskable/512.png new file mode 100644 index 000000000..01d26debf Binary files /dev/null and b/assets/static/icons/mint/maskable/512.png differ diff --git a/assets/static/icons/ring/maskable/192.png b/assets/static/icons/ring/maskable/192.png new file mode 100644 index 000000000..9d0a13264 Binary files /dev/null and b/assets/static/icons/ring/maskable/192.png differ diff --git a/assets/static/icons/ring/maskable/512.png b/assets/static/icons/ring/maskable/512.png new file mode 100644 index 000000000..3fd39ef0a Binary files /dev/null and b/assets/static/icons/ring/maskable/512.png differ diff --git a/assets/static/icons/shutter/maskable/192.png b/assets/static/icons/shutter/maskable/192.png new file mode 100644 index 000000000..33afdda95 Binary files /dev/null and b/assets/static/icons/shutter/maskable/192.png differ diff --git a/assets/static/icons/shutter/maskable/512.png b/assets/static/icons/shutter/maskable/512.png new file mode 100644 index 000000000..f87fc4ea4 Binary files /dev/null and b/assets/static/icons/shutter/maskable/512.png differ diff --git a/assets/static/icons/square/maskable/192.png b/assets/static/icons/square/maskable/192.png new file mode 100644 index 000000000..227e932ae Binary files /dev/null and b/assets/static/icons/square/maskable/192.png differ diff --git a/assets/static/icons/square/maskable/512.png b/assets/static/icons/square/maskable/512.png new file mode 100644 index 000000000..05f58c016 Binary files /dev/null and b/assets/static/icons/square/maskable/512.png differ diff --git a/assets/static/img/screenshots/narrow.jpg b/assets/static/img/screenshots/narrow.jpg new file mode 100644 index 000000000..93dc03ff2 Binary files /dev/null and b/assets/static/img/screenshots/narrow.jpg differ diff --git a/assets/static/img/screenshots/wide.jpg b/assets/static/img/screenshots/wide.jpg new file mode 100644 index 000000000..2cd9be15f Binary files /dev/null and b/assets/static/img/screenshots/wide.jpg differ diff --git a/internal/config/config_app_test.go b/internal/config/config_app_test.go index 81b313a76..79ed8e6f3 100644 --- a/internal/config/config_app_test.go +++ b/internal/config/config_app_test.go @@ -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)) }) diff --git a/internal/config/pwa/icon.go b/internal/config/pwa/icon.go index f550154e5..5e6a85b9f 100644 --- a/internal/config/pwa/icon.go +++ b/internal/config/pwa/icon.go @@ -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 diff --git a/internal/config/pwa/icon_test.go b/internal/config/pwa/icon_test.go index 522d0fb02..e24b7010b 100644 --- a/internal/config/pwa/icon_test.go +++ b/internal/config/pwa/icon_test.go @@ -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) }) } diff --git a/internal/config/pwa/manifest.go b/internal/config/pwa/manifest.go index 8075aca12..5ee0c5510 100644 --- a/internal/config/pwa/manifest.go +++ b/internal/config/pwa/manifest.go @@ -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), } } diff --git a/internal/config/pwa/manifest_test.go b/internal/config/pwa/manifest_test.go index a79da134e..884288463 100644 --- a/internal/config/pwa/manifest_test.go +++ b/internal/config/pwa/manifest_test.go @@ -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" diff --git a/internal/config/pwa/screenshot.go b/internal/config/pwa/screenshot.go new file mode 100644 index 000000000..d97ad2417 --- /dev/null +++ b/internal/config/pwa/screenshot.go @@ -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", + }, + } +} diff --git a/internal/config/pwa/screenshot_test.go b/internal/config/pwa/screenshot_test.go new file mode 100644 index 000000000..8742e444c --- /dev/null +++ b/internal/config/pwa/screenshot_test.go @@ -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) + }) +} diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index 97186f5a6..df8d1343b 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -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() diff --git a/pkg/clean/locale.go b/pkg/clean/locale.go index 3db2a2d69..e42222078 100644 --- a/pkg/clean/locale.go +++ b/pkg/clean/locale.go @@ -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" +} diff --git a/pkg/clean/locale_test.go b/pkg/clean/locale_test.go index c7c9ce8be..d6af87747 100644 --- a/pkg/clean/locale_test.go +++ b/pkg/clean/locale_test.go @@ -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("", "")) + }) +} diff --git a/scripts/render/maskable-icon.sh b/scripts/render/maskable-icon.sh new file mode 100755 index 000000000..e5786f08c --- /dev/null +++ b/scripts/render/maskable-icon.sh @@ -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."