From 826addb4c11f6a02a17c59b145e2fe782ea759a8 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Fri, 10 Feb 2023 15:53:01 +0100 Subject: [PATCH] PWA: Create manifest.json in code without using a template #3181 Signed-off-by: Michael Mayer --- assets/templates/manifest.json | 98 -------------------- frontend/src/common/api.js | 2 +- frontend/tests/unit/config.js | 2 +- internal/config/client_assets.go | 4 +- internal/config/client_assets_test.go | 6 +- internal/config/client_config.go | 4 + internal/config/config.go | 1 + internal/config/config_app.go | 105 ++++++++++++++++++++++ internal/config/config_app_test.go | 109 +++++++++++++++++++++++ internal/config/config_cache.go | 18 ++++ internal/config/config_customize.go | 55 ------------ internal/config/config_customize_test.go | 47 ---------- internal/config/flags.go | 20 +++-- internal/config/options.go | 3 +- internal/config/report.go | 3 +- internal/pwa/categories.go | 8 ++ internal/pwa/config.go | 12 +++ internal/pwa/icon.go | 43 +++++++++ internal/pwa/icon_test.go | 24 +++++ internal/pwa/manifest.go | 45 ++++++++++ internal/pwa/manifest_test.go | 32 +++++++ internal/pwa/permissions.go | 8 ++ internal/pwa/pwa.go | 25 ++++++ internal/pwa/sw.go | 8 ++ internal/server/routes.go | 3 + internal/server/routes_pwa.go | 42 +++++++++ internal/server/routes_sharing.go | 2 +- internal/server/routes_static.go | 31 ------- internal/server/routes_test.go | 69 ++++++++++---- pkg/clean/color.go | 25 ++++++ pkg/clean/color_test.go | 31 +++++++ 31 files changed, 618 insertions(+), 267 deletions(-) delete mode 100644 assets/templates/manifest.json create mode 100644 internal/config/config_app.go create mode 100644 internal/config/config_app_test.go create mode 100644 internal/config/config_cache.go create mode 100644 internal/pwa/categories.go create mode 100644 internal/pwa/config.go create mode 100644 internal/pwa/icon.go create mode 100644 internal/pwa/icon_test.go create mode 100644 internal/pwa/manifest.go create mode 100644 internal/pwa/manifest_test.go create mode 100644 internal/pwa/permissions.go create mode 100644 internal/pwa/pwa.go create mode 100644 internal/pwa/sw.go create mode 100644 internal/server/routes_pwa.go create mode 100644 pkg/clean/color.go create mode 100644 pkg/clean/color_test.go diff --git a/assets/templates/manifest.json b/assets/templates/manifest.json deleted file mode 100644 index 9813a4cce..000000000 --- a/assets/templates/manifest.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "name": "{{ .config.AppName }}", - "categories": ["photo"], - "short_name": "{{ printf "%.32s" .config.AppName }}", - "description": "{{ .config.SiteDescription }}", - "icons": [ - { - "src": "{{ .config.StaticUri }}/icons/{{ .config.AppIcon }}/16.png", - "sizes": "16x16", - "type": "image/png" - }, - { - "src": "{{ .config.StaticUri }}/icons/{{ .config.AppIcon }}/32.png", - "sizes": "32x32", - "type": "image/png" - }, - { - "src": "{{ .config.StaticUri }}/icons/{{ .config.AppIcon }}/72.png", - "sizes": "72x72", - "type": "image/png" - }, - { - "src": "{{ .config.StaticUri }}/icons/{{ .config.AppIcon }}/114.png", - "sizes": "114x114", - "type": "image/png" - }, - { - "src": "{{ .config.StaticUri }}/icons/{{ .config.AppIcon }}/128.png", - "sizes": "128x128", - "type": "image/png" - }, - { - "src": "{{ .config.StaticUri }}/icons/{{ .config.AppIcon }}/144.png", - "sizes": "144x144", - "type": "image/png" - }, - { - "src": "{{ .config.StaticUri }}/icons/{{ .config.AppIcon }}/152.png", - "sizes": "152x152", - "type": "image/png" - }, - { - "src": "{{ .config.StaticUri }}/icons/{{ .config.AppIcon }}/160.png", - "sizes": "160x160", - "type": "image/png" - }, - { - "src": "{{ .config.StaticUri }}/icons/{{ .config.AppIcon }}/167.png", - "sizes": "167x167", - "type": "image/png" - }, - { - "src": "{{ .config.StaticUri }}/icons/{{ .config.AppIcon }}/180.png", - "sizes": "180x180", - "type": "image/png" - }, - { - "src": "{{ .config.StaticUri }}/icons/{{ .config.AppIcon }}/192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "{{ .config.StaticUri }}/icons/{{ .config.AppIcon }}/196.png", - "sizes": "196x196", - "type": "image/png" - }, - { - "src": "{{ .config.StaticUri }}/icons/{{ .config.AppIcon }}/256.png", - "sizes": "256x256", - "type": "image/png" - }, - { - "src": "{{ .config.StaticUri }}/icons/{{ .config.AppIcon }}/400.png", - "sizes": "400x400", - "type": "image/png" - }, - { - "src": "{{ .config.StaticUri }}/icons/{{ .config.AppIcon }}/512.png", - "sizes": "512x512", - "type": "image/png" - } - ], - "scope": "{{ .config.BaseUri }}/", - "start_url": "{{ .config.BaseUri }}/library/", - "serviceworker": { - "src": "sw.js", - "scope": "{{ .config.BaseUri }}/", - "use_cache": true - }, - "display": "{{ .config.AppMode }}", - "theme_color": "#000000", - "background_color": "#000000", - "permissions": [ - "geolocation", - "downloads", - "storage" - ] -} diff --git a/frontend/src/common/api.js b/frontend/src/common/api.js index b8fda8b16..145aea366 100644 --- a/frontend/src/common/api.js +++ b/frontend/src/common/api.js @@ -38,7 +38,7 @@ const testConfig = { downloadToken: "public", cssUri: "/static/build/app.2259c0edcc020e7af593.css", jsUri: "/static/build/app.9bd7132eaee8e4c7c7e3.js", - manifestUri: "/manifest.json?0e41a7e5", + manifestUri: "/manifest.json", }; const c = window.__CONFIG__ ? window.__CONFIG__ : testConfig; diff --git a/frontend/tests/unit/config.js b/frontend/tests/unit/config.js index ee7bb6062..692358c51 100644 --- a/frontend/tests/unit/config.js +++ b/frontend/tests/unit/config.js @@ -287,7 +287,7 @@ const clientConfig = { previewToken: "public", cssUri: "/static/build/app.2259c0edcc020e7af593.css", jsUri: "/static/build/app.9bd7132eaee8e4c7c7e3.js", - manifestUri: "/manifest.json?0e41a7e5", + manifestUri: "/manifest.json", settings: { ui: { scrollbar: true, diff --git a/internal/config/client_assets.go b/internal/config/client_assets.go index fc0c6c193..e66f6ce30 100644 --- a/internal/config/client_assets.go +++ b/internal/config/client_assets.go @@ -5,8 +5,6 @@ import ( "fmt" "os" "path/filepath" - - "github.com/photoprism/photoprism/pkg/fs" ) type ClientAssets struct { @@ -87,5 +85,5 @@ func (c *Config) ClientAssets() ClientAssets { // ClientManifestUri returns the frontend manifest.json URI. func (c *Config) ClientManifestUri() string { - return fmt.Sprintf("%s?%d", c.BaseUri("/manifest.json"), fs.BirthTime(c.TemplatesPath()+"/manifest.json").Unix()) + return c.BaseUri("/manifest.json") } diff --git a/internal/config/client_assets_test.go b/internal/config/client_assets_test.go index b1e235271..6877fc80b 100644 --- a/internal/config/client_assets_test.go +++ b/internal/config/client_assets_test.go @@ -85,13 +85,13 @@ func TestConfig_ClientAssets(t *testing.T) { func TestClientManifestUri(t *testing.T) { c := NewConfig(CliTestContext()) - assert.True(t, strings.HasPrefix(c.ClientManifestUri(), "/manifest.json?")) + assert.True(t, strings.HasPrefix(c.ClientManifestUri(), "/manifest.json")) c.options.SiteUrl = "" - assert.True(t, strings.HasPrefix(c.ClientManifestUri(), "/manifest.json?")) + assert.True(t, strings.HasPrefix(c.ClientManifestUri(), "/manifest.json")) c.options.SiteUrl = "http://myhost/foo" - assert.True(t, strings.HasPrefix(c.ClientManifestUri(), "/foo/manifest.json?")) + assert.True(t, strings.HasPrefix(c.ClientManifestUri(), "/foo/manifest.json")) } diff --git a/internal/config/client_config.go b/internal/config/client_config.go index ba4a4638d..0726f99cb 100644 --- a/internal/config/client_config.go +++ b/internal/config/client_config.go @@ -50,6 +50,7 @@ type ClientConfig struct { AppName string `json:"appName"` AppMode string `json:"appMode"` AppIcon string `json:"appIcon"` + AppColor string `json:"appColor"` Debug bool `json:"debug"` Trace bool `json:"trace"` Test bool `json:"test"` @@ -253,6 +254,7 @@ func (c *Config) ClientPublic() ClientConfig { AppName: c.AppName(), AppMode: c.AppMode(), AppIcon: c.AppIcon(), + AppColor: c.AppColor(), WallpaperUri: c.WallpaperUri(), Version: c.Version(), Copyright: c.Copyright(), @@ -332,6 +334,7 @@ func (c *Config) ClientShare() ClientConfig { AppName: c.AppName(), AppMode: c.AppMode(), AppIcon: c.AppIcon(), + AppColor: c.AppColor(), WallpaperUri: c.WallpaperUri(), Version: c.Version(), Copyright: c.Copyright(), @@ -418,6 +421,7 @@ func (c *Config) ClientUser(withSettings bool) ClientConfig { AppName: c.AppName(), AppMode: c.AppMode(), AppIcon: c.AppIcon(), + AppColor: c.AppColor(), WallpaperUri: c.WallpaperUri(), Version: c.Version(), Copyright: c.Copyright(), diff --git a/internal/config/config.go b/internal/config/config.go index 69b853d44..70b76d52b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -155,6 +155,7 @@ func (c *Config) Options() *Options { // Propagate updates config options in other packages as needed. func (c *Config) Propagate() { + FlushCache() log.SetLevel(c.LogLevel()) // Set thumbnail generation parameters. diff --git a/internal/config/config_app.go b/internal/config/config_app.go new file mode 100644 index 000000000..4fbea5f57 --- /dev/null +++ b/internal/config/config_app.go @@ -0,0 +1,105 @@ +package config + +import ( + "path/filepath" + "strings" + + "github.com/photoprism/photoprism/internal/pwa" + "github.com/photoprism/photoprism/pkg/clean" + "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/txt" +) + +// AppName returns the app name when installed on a device. +func (c *Config) AppName() string { + name := strings.TrimSpace(c.options.AppName) + + if c.NoSponsor() || name == "" { + name = c.SiteTitle() + } + + name = strings.Map(func(r rune) rune { + switch r { + case '\'', '"': + return -1 + } + + return r + }, name) + + return txt.Clip(name, 32) +} + +// AppMode returns the app mode when installed on a device. +func (c *Config) AppMode() string { + switch c.options.AppMode { + case "fullscreen", "standalone", "minimal-ui", "browser": + return c.options.AppMode + default: + return "standalone" + } +} + +// AppIcon returns the app icon when installed on a device. +func (c *Config) AppIcon() string { + defaultIcon := "logo" + + if c.NoSponsor() || c.options.AppIcon == "" || c.options.AppIcon == defaultIcon { + // Default. + } else if strings.Contains(c.options.AppIcon, "/") { + return c.options.AppIcon + } else if fs.FileExists(c.AppIconsPath(c.options.AppIcon, "16.png")) { + return c.options.AppIcon + } + + return defaultIcon +} + +// AppColor returns the app splash screen color when installed on a device. +func (c *Config) AppColor() string { + if appColor := clean.Color(c.options.AppColor); appColor == "" { + return "#000000" + } else { + return appColor + } +} + +// AppIconsPath returns the path to the app icons. +func (c *Config) AppIconsPath(name ...string) string { + if len(name) > 0 { + filePath := []string{c.StaticPath(), "icons"} + filePath = append(filePath, name...) + return filepath.Join(filePath...) + } + + return filepath.Join(c.StaticPath(), "icons") +} + +// AppConfig returns the progressive web app config. +func (c *Config) AppConfig() pwa.Config { + return pwa.Config{ + Icon: c.AppIcon(), + Color: c.AppColor(), + Name: c.AppName(), + Description: c.SiteDescription(), + Mode: c.AppMode(), + BaseUri: c.BaseUri("/"), + StaticUri: c.StaticUri(), + } +} + +// AppManifest returns the progressive web app manifest. +func (c *Config) AppManifest() *pwa.Manifest { + if cacheData, ok := Cache.Get(CacheKeyAppManifest); ok { + log.Tracef("config: cache hit for %s", CacheKeyAppManifest) + + return cacheData.(*pwa.Manifest) + } + result := pwa.NewManifest(c.AppConfig()) + if result != nil { + Cache.SetDefault(CacheKeyAppManifest, result) + } else { + log.Warnf("config: web app manifest is nil - possible bug") + } + return result +} diff --git a/internal/config/config_app_test.go b/internal/config/config_app_test.go new file mode 100644 index 000000000..d14618558 --- /dev/null +++ b/internal/config/config_app_test.go @@ -0,0 +1,109 @@ +package config + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/internal/pwa" +) + +func TestConfig_AppName(t *testing.T) { + c := NewConfig(CliTestContext()) + + assert.Equal(t, "PhotoPrism", c.AppName()) +} + +func TestConfig_AppMode(t *testing.T) { + c := NewConfig(CliTestContext()) + + assert.Equal(t, "standalone", c.AppMode()) +} + +func TestConfig_AppIcon(t *testing.T) { + c := NewConfig(CliTestContext()) + + assert.Equal(t, "logo", c.AppIcon()) + c.options.AppIcon = "foo" + assert.Equal(t, "logo", c.AppIcon()) + c.options.AppIcon = "app" + assert.Equal(t, "app", c.AppIcon()) + c.options.AppIcon = "crisp" + assert.Equal(t, "crisp", c.AppIcon()) + c.options.AppIcon = "mint" + assert.Equal(t, "mint", c.AppIcon()) + c.options.AppIcon = "bold" + assert.Equal(t, "bold", c.AppIcon()) + c.options.AppIcon = "logo" + assert.Equal(t, "logo", c.AppIcon()) +} + +func TestConfig_AppColor(t *testing.T) { + c := NewConfig(CliTestContext()) + + assert.Equal(t, "#000000", c.AppColor()) + c.options.AppColor = "#aBC123" + assert.Equal(t, "#abc123", c.AppColor()) + c.options.AppColor = "" + assert.Equal(t, "#000000", c.AppColor()) +} + +func TestConfig_AppIconsPath(t *testing.T) { + c := NewConfig(CliTestContext()) + + if p := c.AppIconsPath(); !strings.HasSuffix(p, "photoprism/assets/static/icons") { + t.Fatal("path .../photoprism/assets/static/icons expected") + } + + if p := c.AppIconsPath("app"); !strings.HasSuffix(p, "photoprism/assets/static/icons/app") { + t.Fatal("path .../photoprism/assets/static/icons/app expected") + } + + if p := c.AppIconsPath("app", "512.png"); !strings.HasSuffix(p, "photoprism/assets/static/icons/app/512.png") { + t.Fatal("path .../photoprism/assets/static/icons/app/512.png expected") + } +} + +func TestConfig_AppConfig(t *testing.T) { + c := NewConfig(CliTestContext()) + + result := c.AppConfig() + assert.NotEmpty(t, result) + assert.Equal(t, c.AppName(), result.Name) + assert.Equal(t, c.AppIcon(), result.Icon) + assert.Equal(t, c.SiteDescription(), result.Description) + assert.Equal(t, c.BaseUri("/"), result.BaseUri) + assert.Equal(t, c.StaticUri(), result.StaticUri) +} + +func TestConfig_AppManifest(t *testing.T) { + c := NewConfig(CliTestContext()) + + appConf := c.AppConfig() + assert.NotEmpty(t, appConf) + + t.Run("Cached", func(t *testing.T) { + result := c.AppManifest() + assert.NotEmpty(t, result) + assert.Equal(t, appConf.Name, result.Name) + assert.Equal(t, appConf.Name, result.ShortName) + assert.Equal(t, appConf.Description, result.Description) + assert.Equal(t, appConf.BaseUri, result.Scope) + assert.Equal(t, appConf.BaseUri+"library/", result.StartUrl) + assert.Len(t, result.Icons, len(pwa.IconSizes)) + assert.Len(t, result.Categories, len(pwa.Categories)) + assert.Len(t, result.Permissions, len(pwa.Permissions)) + + cached := c.AppManifest() + assert.NotEmpty(t, cached) + assert.Equal(t, appConf.Name, cached.Name) + assert.Equal(t, appConf.Name, cached.ShortName) + assert.Equal(t, appConf.Description, cached.Description) + assert.Equal(t, appConf.BaseUri, cached.Scope) + assert.Equal(t, appConf.BaseUri+"library/", cached.StartUrl) + assert.Len(t, cached.Icons, len(pwa.IconSizes)) + assert.Len(t, cached.Categories, len(pwa.Categories)) + assert.Len(t, cached.Permissions, len(pwa.Permissions)) + }) +} diff --git a/internal/config/config_cache.go b/internal/config/config_cache.go new file mode 100644 index 000000000..ff68fa29a --- /dev/null +++ b/internal/config/config_cache.go @@ -0,0 +1,18 @@ +package config + +import ( + "time" + + gc "github.com/patrickmn/go-cache" +) + +var Cache = gc.New(time.Hour, 15*time.Minute) + +const ( + CacheKeyAppManifest = "app-manifest" +) + +// FlushCache clears the config cache. +func FlushCache() { + Cache.Flush() +} diff --git a/internal/config/config_customize.go b/internal/config/config_customize.go index 5788edb1c..daab6c961 100644 --- a/internal/config/config_customize.go +++ b/internal/config/config_customize.go @@ -8,7 +8,6 @@ import ( "github.com/photoprism/photoprism/internal/i18n" "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" - "github.com/photoprism/photoprism/pkg/txt" ) // DefaultTheme returns the default user interface theme name. @@ -29,60 +28,6 @@ func (c *Config) DefaultLocale() string { return c.options.DefaultLocale } -// AppIcon returns the app icon when installed on a device. -func (c *Config) AppIcon() string { - defaultIcon := "logo" - - if c.NoSponsor() || c.options.AppIcon == "" || c.options.AppIcon == defaultIcon { - // Default. - } else if fs.FileExists(c.AppIconsPath(c.options.AppIcon, "512.png")) { - return c.options.AppIcon - } - - return defaultIcon -} - -// AppIconsPath returns the path to the app icons. -func (c *Config) AppIconsPath(name ...string) string { - if len(name) > 0 { - filePath := []string{c.StaticPath(), "icons"} - filePath = append(filePath, name...) - return filepath.Join(filePath...) - } - - return filepath.Join(c.StaticPath(), "icons") -} - -// AppName returns the app name when installed on a device. -func (c *Config) AppName() string { - name := strings.TrimSpace(c.options.AppName) - - if c.NoSponsor() || name == "" { - name = c.SiteTitle() - } - - name = strings.Map(func(r rune) rune { - switch r { - case '\'', '"': - return -1 - } - - return r - }, name) - - return txt.Clip(name, 32) -} - -// AppMode returns the app mode when installed on a device. -func (c *Config) AppMode() string { - switch c.options.AppMode { - case "fullscreen", "standalone", "minimal-ui", "browser": - return c.options.AppMode - default: - return "standalone" - } -} - // WallpaperUri returns the login screen background image `URI`. func (c *Config) WallpaperUri() string { if c.NoSponsor() { diff --git a/internal/config/config_customize_test.go b/internal/config/config_customize_test.go index fbe79420d..5eccdb550 100644 --- a/internal/config/config_customize_test.go +++ b/internal/config/config_customize_test.go @@ -1,7 +1,6 @@ package config import ( - "strings" "testing" "github.com/stretchr/testify/assert" @@ -41,52 +40,6 @@ func TestConfig_DefaultLocale(t *testing.T) { assert.Equal(t, "en", c.DefaultLocale()) } -func TestConfig_AppIcon(t *testing.T) { - c := NewConfig(CliTestContext()) - - assert.Equal(t, "logo", c.AppIcon()) - c.options.AppIcon = "foo" - assert.Equal(t, "logo", c.AppIcon()) - c.options.AppIcon = "app" - assert.Equal(t, "app", c.AppIcon()) - c.options.AppIcon = "crisp" - assert.Equal(t, "crisp", c.AppIcon()) - c.options.AppIcon = "mint" - assert.Equal(t, "mint", c.AppIcon()) - c.options.AppIcon = "bold" - assert.Equal(t, "bold", c.AppIcon()) - c.options.AppIcon = "logo" - assert.Equal(t, "logo", c.AppIcon()) -} - -func TestConfig_AppIconsPath(t *testing.T) { - c := NewConfig(CliTestContext()) - - if p := c.AppIconsPath(); !strings.HasSuffix(p, "photoprism/assets/static/icons") { - t.Fatal("path .../photoprism/assets/static/icons expected") - } - - if p := c.AppIconsPath("app"); !strings.HasSuffix(p, "photoprism/assets/static/icons/app") { - t.Fatal("path .../pphotoprism/assets/static/icons/app expected") - } - - if p := c.AppIconsPath("app", "512.png"); !strings.HasSuffix(p, "photoprism/assets/static/icons/app/512.png") { - t.Fatal("path .../photoprism/assets/static/icons/app/512.png expected") - } -} - -func TestConfig_AppName(t *testing.T) { - c := NewConfig(CliTestContext()) - - assert.Equal(t, "PhotoPrism", c.AppName()) -} - -func TestConfig_AppMode(t *testing.T) { - c := NewConfig(CliTestContext()) - - assert.Equal(t, "standalone", c.AppMode()) -} - func TestConfig_WallpaperUri(t *testing.T) { c := NewConfig(CliTestContext()) diff --git a/internal/config/flags.go b/internal/config/flags.go index f4982b243..3f2462ef4 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -311,6 +311,13 @@ var Flags = CliFlags{ EnvVar: "PHOTOPRISM_DEFAULT_THEME", }, Tags: []string{EnvSponsor}}, { + Flag: cli.StringFlag{ + Name: "app-name", + Usage: "progressive web app `NAME` when installed on a device", + Value: "", + EnvVar: "PHOTOPRISM_APP_NAME", + }, + Tags: []string{EnvSponsor}}, { Flag: cli.StringFlag{ Name: "app-mode", Usage: "progressive web app `MODE` (fullscreen, standalone, minimal-ui, browser)", @@ -319,17 +326,16 @@ var Flags = CliFlags{ }}, { Flag: cli.StringFlag{ Name: "app-icon", - Usage: "progressive web app `ICON` (logo, app, crisp, mint, bold)", + Usage: "home screen `ICON` (logo, app, crisp, mint, bold)", EnvVar: "PHOTOPRISM_APP_ICON", }, Tags: []string{EnvSponsor}}, { Flag: cli.StringFlag{ - Name: "app-name", - Usage: "progressive web app `NAME` when installed on a device", - Value: "", - EnvVar: "PHOTOPRISM_APP_NAME", - }, - Tags: []string{EnvSponsor}}, { + Name: "app-color", + Usage: "splash screen `COLOR` code", + EnvVar: "PHOTOPRISM_APP_COLOR", + Value: "#000000", + }}, { Flag: cli.StringFlag{ Name: "imprint", Usage: "legal information `TEXT`, displayed in the page footer", diff --git a/internal/config/options.go b/internal/config/options.go index 397dd6f97..4b039ea92 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -81,9 +81,10 @@ type Options struct { UploadNSFW bool `yaml:"UploadNSFW" json:"-" flag:"upload-nsfw"` DefaultTheme string `yaml:"DefaultTheme" json:"DefaultTheme" flag:"default-theme"` DefaultLocale string `yaml:"DefaultLocale" json:"DefaultLocale" flag:"default-locale"` - AppIcon string `yaml:"AppIcon" json:"AppIcon" flag:"app-icon"` AppName string `yaml:"AppName" json:"AppName" flag:"app-name"` AppMode string `yaml:"AppMode" json:"AppMode" flag:"app-mode"` + AppIcon string `yaml:"AppIcon" json:"AppIcon" flag:"app-icon"` + AppColor string `yaml:"AppColor" json:"AppColor" flag:"app-color"` LegalInfo string `yaml:"LegalInfo" json:"LegalInfo" flag:"legal-info"` LegalUrl string `yaml:"LegalUrl" json:"LegalUrl" flag:"legal-url"` WallpaperUri string `yaml:"WallpaperUri" json:"WallpaperUri" flag:"wallpaper-uri"` diff --git a/internal/config/report.go b/internal/config/report.go index 5fa65816c..b447891bd 100644 --- a/internal/config/report.go +++ b/internal/config/report.go @@ -112,9 +112,10 @@ func (c *Config) Report() (rows [][]string, cols []string) { // Customization. {"default-locale", c.DefaultLocale()}, {"default-theme", c.DefaultTheme()}, - {"app-icon", c.AppIcon()}, {"app-name", c.AppName()}, {"app-mode", c.AppMode()}, + {"app-icon", c.AppIcon()}, + {"app-color", c.AppColor()}, {"legal-info", c.LegalInfo()}, {"legal-url", c.LegalUrl()}, {"wallpaper-uri", c.WallpaperUri()}, diff --git a/internal/pwa/categories.go b/internal/pwa/categories.go new file mode 100644 index 000000000..35e9a7e94 --- /dev/null +++ b/internal/pwa/categories.go @@ -0,0 +1,8 @@ +package pwa + +import ( + "github.com/photoprism/photoprism/pkg/list" +) + +// Categories specifies the default web app manifest categories. +var Categories = list.List{"photo"} diff --git a/internal/pwa/config.go b/internal/pwa/config.go new file mode 100644 index 000000000..ccd645b63 --- /dev/null +++ b/internal/pwa/config.go @@ -0,0 +1,12 @@ +package pwa + +// Config represents progressive web app manifest config values. +type Config struct { + Icon string `json:"icon"` + Color string `json:"color"` + Name string `json:"name"` + Description string `json:"description"` + Mode string `json:"mode"` + BaseUri string `json:"baseUri"` + StaticUri string `json:"staticUri"` +} diff --git a/internal/pwa/icon.go b/internal/pwa/icon.go new file mode 100644 index 000000000..bd6701e06 --- /dev/null +++ b/internal/pwa/icon.go @@ -0,0 +1,43 @@ +package pwa + +import ( + "fmt" + "strings" +) + +// Icons represents a list of app icons. +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"` +} + +// IconSizes represents standard app icon sizes. +var IconSizes = []int{16, 32, 77, 114, 128, 144, 152, 160, 167, 180, 192, 196, 256, 400, 512} + +// NewIcons creates new app icons in the default sizes based on the parameters provided. +func NewIcons(staticUri, appIcon string) Icons { + if appIcon == "" { + appIcon = "logo" + } else if strings.Contains(appIcon, "/") { + return Icons{{ + Src: appIcon, + Type: "image/png", + }} + } + + icons := make(Icons, len(IconSizes)) + + for i, d := range IconSizes { + icons[i] = Icon{ + Src: fmt.Sprintf("%s/icons/%s/%d.png", staticUri, appIcon, d), + Sizes: fmt.Sprintf("%dx%d", d, d), + Type: "image/png", + } + } + + return icons +} diff --git a/internal/pwa/icon_test.go b/internal/pwa/icon_test.go new file mode 100644 index 000000000..8984cfcf8 --- /dev/null +++ b/internal/pwa/icon_test.go @@ -0,0 +1,24 @@ +package pwa + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewIcons(t *testing.T) { + t.Run("Standard", func(t *testing.T) { + result := NewIcons("https://demo-cdn.photoprism.app/static", "test") + assert.NotEmpty(t, result) + 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) + }) + t.Run("Custom", func(t *testing.T) { + result := NewIcons("https://demo-cdn.photoprism.app/static", "/test.png") + assert.NotEmpty(t, result) + assert.Equal(t, "/test.png", result[0].Src) + assert.Equal(t, "image/png", result[0].Type) + assert.Equal(t, "", result[0].Sizes) + }) +} diff --git a/internal/pwa/manifest.go b/internal/pwa/manifest.go new file mode 100644 index 000000000..4b270f807 --- /dev/null +++ b/internal/pwa/manifest.go @@ -0,0 +1,45 @@ +package pwa + +import ( + "github.com/photoprism/photoprism/pkg/clean" + "github.com/photoprism/photoprism/pkg/list" + "github.com/photoprism/photoprism/pkg/txt" +) + +// Manifest represents a progressive web app manifest. +type Manifest struct { + Name string `json:"name"` + ShortName string `json:"short_name,omitempty"` + Description string `json:"description,omitempty"` + Categories list.List `json:"categories"` + Display string `json:"display"` + ThemeColor string `json:"theme_color"` + BackgroundColor string `json:"background_color"` + Scope string `json:"scope"` + StartUrl string `json:"start_url,omitempty"` + Serviceworker Serviceworker `json:"serviceworker,omitempty"` + Permissions list.List `json:"permissions"` + Icons Icons `json:"icons"` +} + +// NewManifest creates a new progressive web app manifest based on the config provided. +func NewManifest(c Config) (m *Manifest) { + return &Manifest{ + Name: c.Name, + ShortName: txt.Clip(c.Name, 32), + Description: c.Description, + Categories: Categories, + Display: c.Mode, + ThemeColor: clean.Color(c.Color), + BackgroundColor: clean.Color(c.Color), + Scope: c.BaseUri, + StartUrl: c.BaseUri + "library/", + Serviceworker: Serviceworker{ + Src: "sw.js", + Scope: c.BaseUri, + UseCache: true, + }, + Permissions: Permissions, + Icons: NewIcons(c.StaticUri, c.Icon), + } +} diff --git a/internal/pwa/manifest_test.go b/internal/pwa/manifest_test.go new file mode 100644 index 000000000..76b178c09 --- /dev/null +++ b/internal/pwa/manifest_test.go @@ -0,0 +1,32 @@ +package pwa + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewManifest(t *testing.T) { + c := Config{ + Icon: "logo", + Color: "#aaaaaa", + Name: "TestPrism+", + Description: "App's Description", + Mode: "fullscreen", + BaseUri: "/", + StaticUri: "/static", + } + + t.Run("Standard", func(t *testing.T) { + result := NewManifest(c) + assert.NotEmpty(t, result) + assert.Equal(t, c.Name, result.Name) + assert.Equal(t, c.Name, result.ShortName) + assert.Equal(t, c.Description, result.Description) + assert.Equal(t, c.BaseUri, result.Scope) + assert.Equal(t, c.BaseUri+"library/", result.StartUrl) + assert.Len(t, result.Icons, len(IconSizes)) + assert.Len(t, result.Categories, len(Categories)) + assert.Len(t, result.Permissions, len(Permissions)) + }) +} diff --git a/internal/pwa/permissions.go b/internal/pwa/permissions.go new file mode 100644 index 000000000..058fddab4 --- /dev/null +++ b/internal/pwa/permissions.go @@ -0,0 +1,8 @@ +package pwa + +import ( + "github.com/photoprism/photoprism/pkg/list" +) + +// Permissions specifies the default web app manifest permissions. +var Permissions = list.List{"geolocation", "downloads", "storage"} diff --git a/internal/pwa/pwa.go b/internal/pwa/pwa.go new file mode 100644 index 000000000..8f64ab292 --- /dev/null +++ b/internal/pwa/pwa.go @@ -0,0 +1,25 @@ +/* +Package pwa provides data structures and tools for working with progressive web applications. + +Copyright (c) 2018 - 2023 PhotoPrism UG. All rights reserved. + + This program is free software: you can redistribute it and/or modify + it under Version 3 of the GNU Affero General Public License (the "AGPL"): + + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + The AGPL is supplemented by our Trademark and Brand Guidelines, + which describe how our Brand Assets may be used: + + +Feel free to send an email to hello@photoprism.app if you have questions, +want to support our work, or just want to say hello. + +Additional information can be found in our Developer Guide: + +*/ +package pwa diff --git a/internal/pwa/sw.go b/internal/pwa/sw.go new file mode 100644 index 000000000..407383b2f --- /dev/null +++ b/internal/pwa/sw.go @@ -0,0 +1,8 @@ +package pwa + +// Serviceworker represents serviceworker options for the PWA manifest. +type Serviceworker struct { + Src string `json:"src"` + Scope string `json:"scope"` + UseCache bool `json:"use_cache"` +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 6985e20a6..5c72294e4 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -16,6 +16,9 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { // Static assets and templates. registerStaticRoutes(router, conf) + // Web app bootstrapping and configuration. + registerPWARoutes(router, conf) + // Built-in WebDAV server. registerWebDAVRoutes(router, conf) diff --git a/internal/server/routes_pwa.go b/internal/server/routes_pwa.go new file mode 100644 index 000000000..ea02167be --- /dev/null +++ b/internal/server/routes_pwa.go @@ -0,0 +1,42 @@ +package server + +import ( + "net/http" + "path/filepath" + + "github.com/gin-gonic/gin" + + "github.com/photoprism/photoprism/internal/config" +) + +// registerPWARoutes configures the progressive web app bootstrap and config routes. +func registerPWARoutes(router *gin.Engine, conf *config.Config) { + // Loads Progressive Web App (PWA) on all routes beginning with "library". + pwa := func(c *gin.Context) { + values := gin.H{ + "signUp": gin.H{"message": config.MsgSponsor, "url": config.SignUpURL}, + "config": conf.ClientPublic(), + } + c.HTML(http.StatusOK, conf.TemplateName(), values) + } + router.Any(conf.BaseUri("/library/*path"), pwa) + + // Progressive Web App (PWA) Manifest. + manifest := func(c *gin.Context) { + c.Header("Cache-Control", "no-store") + c.Header("Content-Type", "application/json") + c.IndentedJSON(200, conf.AppManifest()) + } + router.Any(conf.BaseUri("/manifest.json"), manifest) + + // Progressive Web App (PWA) Service Worker. + swWorker := func(c *gin.Context) { + c.Header("Cache-Control", "no-store") + c.File(filepath.Join(conf.BuildPath(), "sw.js")) + } + router.Any("/sw.js", swWorker) + + if swUri := conf.BaseUri("/sw.js"); swUri != "/sw.js" { + router.Any(swUri, swWorker) + } +} diff --git a/internal/server/routes_sharing.go b/internal/server/routes_sharing.go index 92ce90ece..bd99d5781 100644 --- a/internal/server/routes_sharing.go +++ b/internal/server/routes_sharing.go @@ -2,8 +2,8 @@ package server import ( "github.com/gin-gonic/gin" - "github.com/photoprism/photoprism/internal/api" + "github.com/photoprism/photoprism/internal/api" "github.com/photoprism/photoprism/internal/config" ) diff --git a/internal/server/routes_static.go b/internal/server/routes_static.go index feaeb68ee..92dc6cdd6 100644 --- a/internal/server/routes_static.go +++ b/internal/server/routes_static.go @@ -34,37 +34,6 @@ func registerStaticRoutes(router *gin.Engine, conf *config.Config) { } }) - // Loads Progressive Web App (PWA) on all routes beginning with "library". - pwa := func(c *gin.Context) { - values := gin.H{ - "signUp": gin.H{"message": config.MsgSponsor, "url": config.SignUpURL}, - "config": conf.ClientPublic(), - } - c.HTML(http.StatusOK, conf.TemplateName(), values) - } - router.Any(conf.BaseUri("/library/*path"), pwa) - - // Progressive Web App (PWA) Manifest. - manifest := func(c *gin.Context) { - c.Header("Cache-Control", "no-store") - c.Header("Content-Type", "application/json") - - clientConfig := conf.ClientPublic() - c.HTML(http.StatusOK, "manifest.json", gin.H{"config": clientConfig}) - } - router.Any(conf.BaseUri("/manifest.json"), manifest) - - // Progressive Web App (PWA) Service Worker. - swWorker := func(c *gin.Context) { - c.Header("Cache-Control", "no-store") - c.File(filepath.Join(conf.BuildPath(), "sw.js")) - } - router.Any("/sw.js", swWorker) - - if swUri := conf.BaseUri("/sw.js"); swUri != "/sw.js" { - router.Any(swUri, swWorker) - } - // Serves static favicon. router.StaticFile(conf.BaseUri("/favicon.ico"), filepath.Join(conf.ImgPath(), "favicon.ico")) diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index ea2c25022..2745c3dce 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -3,6 +3,7 @@ package server import ( "net/http" "net/http/httptest" + "strings" "testing" "github.com/gin-gonic/gin" @@ -24,33 +25,35 @@ func TestStaticRoutes(t *testing.T) { // Register routes. registerStaticRoutes(r, conf) - t.Run("GetHome", func(t *testing.T) { + t.Run("GetRoot", func(t *testing.T) { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/", nil) r.ServeHTTP(w, req) assert.Equal(t, 307, w.Code) assert.Equal(t, "Temporary Redirect.\n\n", w.Body.String()) }) - t.Run("HeadHome", func(t *testing.T) { + t.Run("HeadRoot", func(t *testing.T) { w := httptest.NewRecorder() req, _ := http.NewRequest("HEAD", "/", nil) r.ServeHTTP(w, req) assert.Equal(t, 307, w.Code) }) - t.Run("GetServiceWorker", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/sw.js", nil) - r.ServeHTTP(w, req) - assert.Equal(t, 200, w.Code) - assert.NotEmpty(t, w.Body) - }) - t.Run("HeadServiceWorker", func(t *testing.T) { - w := httptest.NewRecorder() - req, _ := http.NewRequest("HEAD", "/sw.js", nil) - r.ServeHTTP(w, req) - assert.Equal(t, 200, w.Code) - assert.Empty(t, w.Body) - }) +} + +func TestPWARoutes(t *testing.T) { + // Create router. + r := gin.Default() + + // Get test config. + conf := config.TestConfig() + + // Find and load templates. + r.LoadHTMLFiles(conf.TemplateFiles()...) + + // Register routes. + registerPWARoutes(r, conf) + + // Bootstrapping. t.Run("GetLibrary", func(t *testing.T) { w := httptest.NewRecorder() req, _ := http.NewRequest("GET", "/library/", nil) @@ -58,9 +61,9 @@ func TestStaticRoutes(t *testing.T) { assert.Equal(t, 200, w.Code) assert.NotEmpty(t, w.Body) }) - t.Run("GetLibrary", func(t *testing.T) { + t.Run("HeadLibrary", func(t *testing.T) { w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/library/", nil) + req, _ := http.NewRequest("HEAD", "/library/", nil) r.ServeHTTP(w, req) assert.Equal(t, 200, w.Code) assert.NotEmpty(t, w.Body) @@ -78,4 +81,34 @@ func TestStaticRoutes(t *testing.T) { r.ServeHTTP(w, req) assert.Equal(t, 200, w.Code) }) + + // Manifest. + t.Run("GetManifest", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/manifest.json", nil) + r.ServeHTTP(w, req) + assert.Equal(t, 200, w.Code) + assert.NotEmpty(t, w.Body.String()) + manifest := w.Body.String() + t.Logf("PWA Manifest: %s", manifest) + assert.True(t, strings.Contains(manifest, `"scope": "/",`)) + assert.True(t, strings.Contains(manifest, `"start_url": "/library/",`)) + assert.True(t, strings.Contains(manifest, "/static/icons/logo/128.png")) + }) + + // Service worker. + t.Run("GetServiceWorker", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/sw.js", nil) + r.ServeHTTP(w, req) + assert.Equal(t, 200, w.Code) + assert.NotEmpty(t, w.Body) + }) + t.Run("HeadServiceWorker", func(t *testing.T) { + w := httptest.NewRecorder() + req, _ := http.NewRequest("HEAD", "/sw.js", nil) + r.ServeHTTP(w, req) + assert.Equal(t, 200, w.Code) + assert.Empty(t, w.Body) + }) } diff --git a/pkg/clean/color.go b/pkg/clean/color.go new file mode 100644 index 000000000..4d5eee3f9 --- /dev/null +++ b/pkg/clean/color.go @@ -0,0 +1,25 @@ +package clean + +import ( + "strings" +) + +// Color sanitizes HTML color codes and returns them in lowercase if they are valid, or an empty string otherwise. +func Color(s string) string { + s = strings.ToLower(s) + + // Remove unwanted characters. + s = strings.Map(func(r rune) rune { + if r < 48 && r > 57 && 97 < r && r > 102 && r != 35 { + return -1 + } + return r + }, s) + + // Invalid? + if l := len(s); l != 4 && l != 7 && l != 9 { + return "" + } + + return s +} diff --git a/pkg/clean/color_test.go b/pkg/clean/color_test.go new file mode 100644 index 000000000..423ea1408 --- /dev/null +++ b/pkg/clean/color_test.go @@ -0,0 +1,31 @@ +package clean + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestColor(t *testing.T) { + t.Run("Empty", func(t *testing.T) { + assert.Equal(t, "", Color("")) + }) + t.Run("Black", func(t *testing.T) { + assert.Equal(t, "#000000", Color("#000000")) + }) + t.Run("White", func(t *testing.T) { + assert.Equal(t, "#ffffff", Color("#FFFFFF")) + }) + t.Run("Short", func(t *testing.T) { + assert.Equal(t, "#ab1", Color("#aB1")) + }) + t.Run("Alpha", func(t *testing.T) { + assert.Equal(t, "#0123456a", Color("#0123456A")) + }) + t.Run("TooLong", func(t *testing.T) { + assert.Equal(t, "", Color("#01234567AA")) + }) + t.Run("TooShort", func(t *testing.T) { + assert.Equal(t, "", Color("#00")) + }) +}