PWA: Create manifest.json in code without using a template #3181
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
parent
77b97f78f7
commit
826addb4c1
31 changed files with 618 additions and 267 deletions
|
@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"))
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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.
|
||||||
|
|
105
internal/config/config_app.go
Normal file
105
internal/config/config_app.go
Normal 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
|
||||||
|
}
|
109
internal/config/config_app_test.go
Normal file
109
internal/config/config_app_test.go
Normal 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))
|
||||||
|
})
|
||||||
|
}
|
18
internal/config/config_cache.go
Normal file
18
internal/config/config_cache.go
Normal 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()
|
||||||
|
}
|
|
@ -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() {
|
||||||
|
|
|
@ -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())
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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"`
|
||||||
|
|
|
@ -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()},
|
||||||
|
|
8
internal/pwa/categories.go
Normal file
8
internal/pwa/categories.go
Normal 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
12
internal/pwa/config.go
Normal 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
43
internal/pwa/icon.go
Normal 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
24
internal/pwa/icon_test.go
Normal 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
45
internal/pwa/manifest.go
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
32
internal/pwa/manifest_test.go
Normal file
32
internal/pwa/manifest_test.go
Normal 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))
|
||||||
|
})
|
||||||
|
}
|
8
internal/pwa/permissions.go
Normal file
8
internal/pwa/permissions.go
Normal 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
25
internal/pwa/pwa.go
Normal 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
8
internal/pwa/sw.go
Normal 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"`
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
||||||
|
|
42
internal/server/routes_pwa.go
Normal file
42
internal/server/routes_pwa.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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"))
|
||||||
|
|
||||||
|
|
|
@ -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
25
pkg/clean/color.go
Normal 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
31
pkg/clean/color_test.go
Normal 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"))
|
||||||
|
})
|
||||||
|
}
|
Loading…
Reference in a new issue