PWA: Create manifest.json in code without using a template #3181

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2023-02-10 15:53:01 +01:00
parent 77b97f78f7
commit 826addb4c1
31 changed files with 618 additions and 267 deletions

View file

@ -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"
]
}

View file

@ -38,7 +38,7 @@ const testConfig = {
downloadToken: "public", downloadToken: "public",
cssUri: "/static/build/app.2259c0edcc020e7af593.css", cssUri: "/static/build/app.2259c0edcc020e7af593.css",
jsUri: "/static/build/app.9bd7132eaee8e4c7c7e3.js", jsUri: "/static/build/app.9bd7132eaee8e4c7c7e3.js",
manifestUri: "/manifest.json?0e41a7e5", manifestUri: "/manifest.json",
}; };
const c = window.__CONFIG__ ? window.__CONFIG__ : testConfig; const c = window.__CONFIG__ ? window.__CONFIG__ : testConfig;

View file

@ -287,7 +287,7 @@ const clientConfig = {
previewToken: "public", previewToken: "public",
cssUri: "/static/build/app.2259c0edcc020e7af593.css", cssUri: "/static/build/app.2259c0edcc020e7af593.css",
jsUri: "/static/build/app.9bd7132eaee8e4c7c7e3.js", jsUri: "/static/build/app.9bd7132eaee8e4c7c7e3.js",
manifestUri: "/manifest.json?0e41a7e5", manifestUri: "/manifest.json",
settings: { settings: {
ui: { ui: {
scrollbar: true, scrollbar: true,

View file

@ -5,8 +5,6 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"github.com/photoprism/photoprism/pkg/fs"
) )
type ClientAssets struct { type ClientAssets struct {
@ -87,5 +85,5 @@ func (c *Config) ClientAssets() ClientAssets {
// ClientManifestUri returns the frontend manifest.json URI. // ClientManifestUri returns the frontend manifest.json URI.
func (c *Config) ClientManifestUri() string { 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")
} }

View file

@ -85,13 +85,13 @@ func TestConfig_ClientAssets(t *testing.T) {
func TestClientManifestUri(t *testing.T) { func TestClientManifestUri(t *testing.T) {
c := NewConfig(CliTestContext()) c := NewConfig(CliTestContext())
assert.True(t, strings.HasPrefix(c.ClientManifestUri(), "/manifest.json?")) assert.True(t, strings.HasPrefix(c.ClientManifestUri(), "/manifest.json"))
c.options.SiteUrl = "" 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" 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"))
} }

View file

@ -50,6 +50,7 @@ type ClientConfig struct {
AppName string `json:"appName"` AppName string `json:"appName"`
AppMode string `json:"appMode"` AppMode string `json:"appMode"`
AppIcon string `json:"appIcon"` AppIcon string `json:"appIcon"`
AppColor string `json:"appColor"`
Debug bool `json:"debug"` Debug bool `json:"debug"`
Trace bool `json:"trace"` Trace bool `json:"trace"`
Test bool `json:"test"` Test bool `json:"test"`
@ -253,6 +254,7 @@ func (c *Config) ClientPublic() ClientConfig {
AppName: c.AppName(), AppName: c.AppName(),
AppMode: c.AppMode(), AppMode: c.AppMode(),
AppIcon: c.AppIcon(), AppIcon: c.AppIcon(),
AppColor: c.AppColor(),
WallpaperUri: c.WallpaperUri(), WallpaperUri: c.WallpaperUri(),
Version: c.Version(), Version: c.Version(),
Copyright: c.Copyright(), Copyright: c.Copyright(),
@ -332,6 +334,7 @@ func (c *Config) ClientShare() ClientConfig {
AppName: c.AppName(), AppName: c.AppName(),
AppMode: c.AppMode(), AppMode: c.AppMode(),
AppIcon: c.AppIcon(), AppIcon: c.AppIcon(),
AppColor: c.AppColor(),
WallpaperUri: c.WallpaperUri(), WallpaperUri: c.WallpaperUri(),
Version: c.Version(), Version: c.Version(),
Copyright: c.Copyright(), Copyright: c.Copyright(),
@ -418,6 +421,7 @@ func (c *Config) ClientUser(withSettings bool) ClientConfig {
AppName: c.AppName(), AppName: c.AppName(),
AppMode: c.AppMode(), AppMode: c.AppMode(),
AppIcon: c.AppIcon(), AppIcon: c.AppIcon(),
AppColor: c.AppColor(),
WallpaperUri: c.WallpaperUri(), WallpaperUri: c.WallpaperUri(),
Version: c.Version(), Version: c.Version(),
Copyright: c.Copyright(), Copyright: c.Copyright(),

View file

@ -155,6 +155,7 @@ func (c *Config) Options() *Options {
// Propagate updates config options in other packages as needed. // Propagate updates config options in other packages as needed.
func (c *Config) Propagate() { func (c *Config) Propagate() {
FlushCache()
log.SetLevel(c.LogLevel()) log.SetLevel(c.LogLevel())
// Set thumbnail generation parameters. // Set thumbnail generation parameters.

View file

@ -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
}

View file

@ -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))
})
}

View file

@ -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()
}

View file

@ -8,7 +8,6 @@ import (
"github.com/photoprism/photoprism/internal/i18n" "github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt"
) )
// DefaultTheme returns the default user interface theme name. // DefaultTheme returns the default user interface theme name.
@ -29,60 +28,6 @@ func (c *Config) DefaultLocale() string {
return c.options.DefaultLocale 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`. // WallpaperUri returns the login screen background image `URI`.
func (c *Config) WallpaperUri() string { func (c *Config) WallpaperUri() string {
if c.NoSponsor() { if c.NoSponsor() {

View file

@ -1,7 +1,6 @@
package config package config
import ( import (
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -41,52 +40,6 @@ func TestConfig_DefaultLocale(t *testing.T) {
assert.Equal(t, "en", c.DefaultLocale()) 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) { func TestConfig_WallpaperUri(t *testing.T) {
c := NewConfig(CliTestContext()) c := NewConfig(CliTestContext())

View file

@ -311,6 +311,13 @@ var Flags = CliFlags{
EnvVar: "PHOTOPRISM_DEFAULT_THEME", EnvVar: "PHOTOPRISM_DEFAULT_THEME",
}, },
Tags: []string{EnvSponsor}}, { 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{ Flag: cli.StringFlag{
Name: "app-mode", Name: "app-mode",
Usage: "progressive web app `MODE` (fullscreen, standalone, minimal-ui, browser)", Usage: "progressive web app `MODE` (fullscreen, standalone, minimal-ui, browser)",
@ -319,17 +326,16 @@ var Flags = CliFlags{
}}, { }}, {
Flag: cli.StringFlag{ Flag: cli.StringFlag{
Name: "app-icon", 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", EnvVar: "PHOTOPRISM_APP_ICON",
}, },
Tags: []string{EnvSponsor}}, { Tags: []string{EnvSponsor}}, {
Flag: cli.StringFlag{ Flag: cli.StringFlag{
Name: "app-name", Name: "app-color",
Usage: "progressive web app `NAME` when installed on a device", Usage: "splash screen `COLOR` code",
Value: "", EnvVar: "PHOTOPRISM_APP_COLOR",
EnvVar: "PHOTOPRISM_APP_NAME", Value: "#000000",
}, }}, {
Tags: []string{EnvSponsor}}, {
Flag: cli.StringFlag{ Flag: cli.StringFlag{
Name: "imprint", Name: "imprint",
Usage: "legal information `TEXT`, displayed in the page footer", Usage: "legal information `TEXT`, displayed in the page footer",

View file

@ -81,9 +81,10 @@ type Options struct {
UploadNSFW bool `yaml:"UploadNSFW" json:"-" flag:"upload-nsfw"` UploadNSFW bool `yaml:"UploadNSFW" json:"-" flag:"upload-nsfw"`
DefaultTheme string `yaml:"DefaultTheme" json:"DefaultTheme" flag:"default-theme"` DefaultTheme string `yaml:"DefaultTheme" json:"DefaultTheme" flag:"default-theme"`
DefaultLocale string `yaml:"DefaultLocale" json:"DefaultLocale" flag:"default-locale"` 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"` AppName string `yaml:"AppName" json:"AppName" flag:"app-name"`
AppMode string `yaml:"AppMode" json:"AppMode" flag:"app-mode"` 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"` LegalInfo string `yaml:"LegalInfo" json:"LegalInfo" flag:"legal-info"`
LegalUrl string `yaml:"LegalUrl" json:"LegalUrl" flag:"legal-url"` LegalUrl string `yaml:"LegalUrl" json:"LegalUrl" flag:"legal-url"`
WallpaperUri string `yaml:"WallpaperUri" json:"WallpaperUri" flag:"wallpaper-uri"` WallpaperUri string `yaml:"WallpaperUri" json:"WallpaperUri" flag:"wallpaper-uri"`

View file

@ -112,9 +112,10 @@ func (c *Config) Report() (rows [][]string, cols []string) {
// Customization. // Customization.
{"default-locale", c.DefaultLocale()}, {"default-locale", c.DefaultLocale()},
{"default-theme", c.DefaultTheme()}, {"default-theme", c.DefaultTheme()},
{"app-icon", c.AppIcon()},
{"app-name", c.AppName()}, {"app-name", c.AppName()},
{"app-mode", c.AppMode()}, {"app-mode", c.AppMode()},
{"app-icon", c.AppIcon()},
{"app-color", c.AppColor()},
{"legal-info", c.LegalInfo()}, {"legal-info", c.LegalInfo()},
{"legal-url", c.LegalUrl()}, {"legal-url", c.LegalUrl()},
{"wallpaper-uri", c.WallpaperUri()}, {"wallpaper-uri", c.WallpaperUri()},

View file

@ -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"}

12
internal/pwa/config.go Normal file
View file

@ -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"`
}

43
internal/pwa/icon.go Normal file
View file

@ -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
}

24
internal/pwa/icon_test.go Normal file
View file

@ -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)
})
}

45
internal/pwa/manifest.go Normal file
View file

@ -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),
}
}

View file

@ -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))
})
}

View file

@ -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"}

25
internal/pwa/pwa.go Normal file
View file

@ -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"):
<https://docs.photoprism.app/license/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:
<https://www.photoprism.app/trademark>
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:
<https://docs.photoprism.app/developer-guide/>
*/
package pwa

8
internal/pwa/sw.go Normal file
View file

@ -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"`
}

View file

@ -16,6 +16,9 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
// Static assets and templates. // Static assets and templates.
registerStaticRoutes(router, conf) registerStaticRoutes(router, conf)
// Web app bootstrapping and configuration.
registerPWARoutes(router, conf)
// Built-in WebDAV server. // Built-in WebDAV server.
registerWebDAVRoutes(router, conf) registerWebDAVRoutes(router, conf)

View file

@ -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)
}
}

View file

@ -2,8 +2,8 @@ package server
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/api"
"github.com/photoprism/photoprism/internal/api"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
) )

View file

@ -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. // Serves static favicon.
router.StaticFile(conf.BaseUri("/favicon.ico"), filepath.Join(conf.ImgPath(), "favicon.ico")) router.StaticFile(conf.BaseUri("/favicon.ico"), filepath.Join(conf.ImgPath(), "favicon.ico"))

View file

@ -3,6 +3,7 @@ package server
import ( import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -24,33 +25,35 @@ func TestStaticRoutes(t *testing.T) {
// Register routes. // Register routes.
registerStaticRoutes(r, conf) registerStaticRoutes(r, conf)
t.Run("GetHome", func(t *testing.T) { t.Run("GetRoot", func(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/", nil) req, _ := http.NewRequest("GET", "/", nil)
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
assert.Equal(t, 307, w.Code) assert.Equal(t, 307, w.Code)
assert.Equal(t, "<a href=\"/library/browse\">Temporary Redirect</a>.\n\n", w.Body.String()) assert.Equal(t, "<a href=\"/library/browse\">Temporary Redirect</a>.\n\n", w.Body.String())
}) })
t.Run("HeadHome", func(t *testing.T) { t.Run("HeadRoot", func(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
req, _ := http.NewRequest("HEAD", "/", nil) req, _ := http.NewRequest("HEAD", "/", nil)
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
assert.Equal(t, 307, w.Code) assert.Equal(t, 307, w.Code)
}) })
t.Run("GetServiceWorker", func(t *testing.T) { }
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/sw.js", nil) func TestPWARoutes(t *testing.T) {
r.ServeHTTP(w, req) // Create router.
assert.Equal(t, 200, w.Code) r := gin.Default()
assert.NotEmpty(t, w.Body)
}) // Get test config.
t.Run("HeadServiceWorker", func(t *testing.T) { conf := config.TestConfig()
w := httptest.NewRecorder()
req, _ := http.NewRequest("HEAD", "/sw.js", nil) // Find and load templates.
r.ServeHTTP(w, req) r.LoadHTMLFiles(conf.TemplateFiles()...)
assert.Equal(t, 200, w.Code)
assert.Empty(t, w.Body) // Register routes.
}) registerPWARoutes(r, conf)
// Bootstrapping.
t.Run("GetLibrary", func(t *testing.T) { t.Run("GetLibrary", func(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/library/", nil) req, _ := http.NewRequest("GET", "/library/", nil)
@ -58,9 +61,9 @@ func TestStaticRoutes(t *testing.T) {
assert.Equal(t, 200, w.Code) assert.Equal(t, 200, w.Code)
assert.NotEmpty(t, w.Body) assert.NotEmpty(t, w.Body)
}) })
t.Run("GetLibrary", func(t *testing.T) { t.Run("HeadLibrary", func(t *testing.T) {
w := httptest.NewRecorder() w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/library/", nil) req, _ := http.NewRequest("HEAD", "/library/", nil)
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code) assert.Equal(t, 200, w.Code)
assert.NotEmpty(t, w.Body) assert.NotEmpty(t, w.Body)
@ -78,4 +81,34 @@ func TestStaticRoutes(t *testing.T) {
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code) 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)
})
} }

25
pkg/clean/color.go Normal file
View file

@ -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
}

31
pkg/clean/color_test.go Normal file
View file

@ -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"))
})
}