From ed63f06d79b623b039b395abeef95529b2dc1a2c Mon Sep 17 00:00:00 2001 From: Mai-Lapyst Date: Fri, 9 Jan 2026 17:49:29 +0100 Subject: [PATCH] Move web app manifest to a own cache-able route and add a setting to set `"display": "standalone"`; Closes #2638 (#5384) This PR does three things: - First it moves the inline web app manifest into its own route `/manifest.json` - Secondly, it add a setting `pwa.STANDALONE` that can be set to `true` if one wants users to be allowed to "install" forgejo as an pwa into their browser. This usually means an "install app" button, which essentially just creates an shortcut to use a single-tab window for browsing the app / forgejo. - Thirdly since we have now an extra route, it checks if someone placed a `public/manifest.json` in forgejo's custom path; if yes, it's content is served instead. This allows more customization without the need on our side to completly implement every nuance of web app manifests. This closes issue #2638 ### Tests - I added test coverage for Go changes... - [x] in their respective `*_test.go` for unit tests. ### Documentation - [x] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs/pulls/1669) to explain to Forgejo users how to use this change. ### Release notes - [ ] I do not want this change to show in the release notes. - [ ] I want the title to show in the release notes with a link to this pull request. - [x] I want the content of the `release-notes/.md` to be be used for the release notes instead of the title. ## Release notes - Features - [PR](https://codeberg.org/forgejo/forgejo/pulls/5384): [allow forgejo to run as a pwa standalone application & override of the webapp manifest.json via the a custom file in `public/manifest.json`](https://codeberg.org/forgejo/forgejo/pulls/5384) Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/5384 Reviewed-by: Otto Reviewed-by: Lucas Co-authored-by: Mai-Lapyst Co-committed-by: Mai-Lapyst --- custom/conf/app.example.ini | 9 ++++ modules/setting/pwa.go | 64 +++++++++++++++++++++++++ modules/setting/server.go | 48 +------------------ modules/setting/setting.go | 1 + modules/setting/setting_test.go | 13 ++++- release-notes/5384.md | 1 + routers/web/misc/misc.go | 24 ++++++++++ routers/web/web.go | 3 +- services/context/context.go | 3 +- templates/base/head.tmpl | 2 +- tests/integration/web_misc_test.go | 77 ++++++++++++++++++++++++++++++ 11 files changed, 193 insertions(+), 52 deletions(-) create mode 100644 modules/setting/pwa.go create mode 100644 release-notes/5384.md create mode 100644 tests/integration/web_misc_test.go diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 4ae02bb4c2..f9c7a666ce 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -2456,6 +2456,15 @@ LEVEL = Info ;; Enable/Disable RSS/Atom feed ;ENABLE_FEED = true +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;[pwa] +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Enable standalone mode; this allows PWA enabled browsers to "install" the website as an progressive web app, +;; by setting the https://developer.mozilla.org/en-US/docs/Web/Manifest/display property to "standalone". +;STANDALONE = false + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;[markup] diff --git a/modules/setting/pwa.go b/modules/setting/pwa.go new file mode 100644 index 0000000000..2c17efd715 --- /dev/null +++ b/modules/setting/pwa.go @@ -0,0 +1,64 @@ +// Copyright 2024 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package setting + +import ( + "forgejo.org/modules/json" + "forgejo.org/modules/log" +) + +type PwaConfig struct { + Standalone bool +} + +var PWA = PwaConfig{ + Standalone: false, +} + +func loadPWAFrom(rootCfg ConfigProvider) { + sec := rootCfg.Section("pwa") + if err := sec.MapTo(&PWA); err != nil { + log.Fatal("Failed to map [pwa] settings: %v", err) + } +} + +type manifestIcon struct { + Src string `json:"src"` + Type string `json:"type"` + Sizes string `json:"sizes"` +} + +type manifestJSON struct { + Name string `json:"name"` + ShortName string `json:"short_name"` + StartURL string `json:"start_url"` + Icons []manifestIcon `json:"icons"` + Display string `json:"display,omitempty"` +} + +func GetManifestJSON() ([]byte, error) { + manifest := manifestJSON{ + Name: AppName, + ShortName: AppName, + StartURL: AppURL, + Icons: []manifestIcon{ + { + Src: AbsoluteAssetURL + "/assets/img/logo.png", + Type: "image/png", + Sizes: "512x512", + }, + { + Src: AbsoluteAssetURL + "/assets/img/logo.svg", + Type: "image/svg+xml", + Sizes: "512x512", + }, + }, + } + + if PWA.Standalone { + manifest.Display = "standalone" + } + + return json.Marshal(manifest) +} diff --git a/modules/setting/server.go b/modules/setting/server.go index 3ff91d2cde..11c4ff8212 100644 --- a/modules/setting/server.go +++ b/modules/setting/server.go @@ -1,10 +1,10 @@ // Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. // SPDX-License-Identifier: MIT package setting import ( - "encoding/base64" "net" "net/url" "path" @@ -13,7 +13,6 @@ import ( "strings" "time" - "forgejo.org/modules/json" "forgejo.org/modules/log" "forgejo.org/modules/util" @@ -111,50 +110,8 @@ var ( PerWritePerKbTimeout = 10 * time.Second StaticURLPrefix string AbsoluteAssetURL string - - ManifestData string ) -// MakeManifestData generates web app manifest JSON -func MakeManifestData(appName, appURL, absoluteAssetURL string) []byte { - type manifestIcon struct { - Src string `json:"src"` - Type string `json:"type"` - Sizes string `json:"sizes"` - } - - type manifestJSON struct { - Name string `json:"name"` - ShortName string `json:"short_name"` - StartURL string `json:"start_url"` - Icons []manifestIcon `json:"icons"` - } - - bytes, err := json.Marshal(&manifestJSON{ - Name: appName, - ShortName: appName, - StartURL: appURL, - Icons: []manifestIcon{ - { - Src: absoluteAssetURL + "/assets/img/logo.png", - Type: "image/png", - Sizes: "512x512", - }, - { - Src: absoluteAssetURL + "/assets/img/logo.svg", - Type: "image/svg+xml", - Sizes: "512x512", - }, - }, - }) - if err != nil { - log.Error("unable to marshal manifest JSON. Error: %v", err) - return make([]byte, 0) - } - - return bytes -} - // MakeAbsoluteAssetURL returns the absolute asset url prefix without a trailing slash func MakeAbsoluteAssetURL(appURL, staticURLPrefix string) string { parsedPrefix, err := url.Parse(strings.TrimSuffix(staticURLPrefix, "/")) @@ -311,9 +268,6 @@ func loadServerFrom(rootCfg ConfigProvider) { AbsoluteAssetURL = MakeAbsoluteAssetURL(AppURL, StaticURLPrefix) AssetVersion = strings.ReplaceAll(AppVer, "+", "~") // make sure the version string is clear (no real escaping is needed) - manifestBytes := MakeManifestData(AppName, AppURL, AbsoluteAssetURL) - ManifestData = `application/json;base64,` + base64.StdEncoding.EncodeToString(manifestBytes) - var defaultLocalURL string switch Protocol { case HTTPUnix: diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 9644d9b83b..3904e29770 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -113,6 +113,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error { // WARNING: don't change the sequence except you know what you are doing. loadRunModeFrom(cfg) loadLogGlobalFrom(cfg) + loadPWAFrom(cfg) loadServerFrom(cfg) loadSSHFrom(cfg) diff --git a/modules/setting/setting_test.go b/modules/setting/setting_test.go index 1fef9e068a..9867b94aaf 100644 --- a/modules/setting/setting_test.go +++ b/modules/setting/setting_test.go @@ -8,6 +8,7 @@ import ( "testing" "forgejo.org/modules/json" + "forgejo.org/modules/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -29,10 +30,20 @@ func TestMakeAbsoluteAssetURL(t *testing.T) { } func TestMakeManifestData(t *testing.T) { - jsonBytes := MakeManifestData(`Example App '\"`, "https://example.com", "https://example.com/foo/bar") + jsonBytes, err := GetManifestJSON() + require.NoError(t, err) assert.True(t, json.Valid(jsonBytes)) } +func TestMakeManifestDataStandalone(t *testing.T) { + defer test.MockVariableValue(&PWA.Standalone, true)() + + jsonBytes, err := GetManifestJSON() + require.NoError(t, err) + assert.True(t, json.Valid(jsonBytes)) + assert.Contains(t, string(jsonBytes), `"standalone"`) +} + func TestLoadServiceDomainListsForFederation(t *testing.T) { oldAppURL := AppURL oldFederation := Federation diff --git a/release-notes/5384.md b/release-notes/5384.md new file mode 100644 index 0000000000..7eae859176 --- /dev/null +++ b/release-notes/5384.md @@ -0,0 +1 @@ +feat: [allow forgejo to run as a pwa standalone application & override of the webapp manifest.json via the a custom file in `public/manifest.json`](https://codeberg.org/forgejo/forgejo/pulls/5384) \ No newline at end of file diff --git a/routers/web/misc/misc.go b/routers/web/misc/misc.go index 22fdccf79f..2eb12846ae 100644 --- a/routers/web/misc/misc.go +++ b/routers/web/misc/misc.go @@ -1,4 +1,5 @@ // Copyright 2023 The Gitea Authors. All rights reserved. +// Copyright 2026 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package misc @@ -28,6 +29,29 @@ func DummyOK(w http.ResponseWriter, req *http.Request) { w.WriteHeader(http.StatusOK) } +func ManifestJSON(w http.ResponseWriter, req *http.Request) { + httpcache.SetCacheControlInHeader(w.Header(), setting.StaticCacheTime) + w.Header().Add("content-type", "application/manifest+json;charset=UTF-8") + + manifestJSON := util.FilePathJoinAbs(setting.CustomPath, "public/manifest.json") + if ok, _ := util.IsExist(manifestJSON); ok { + http.ServeFile(w, req, manifestJSON) + return + } + + bytes, err := setting.GetManifestJSON() + if err != nil { + log.Error("unable to marshal manifest JSON. Error: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + if _, err := w.Write(bytes); err != nil { + log.Error("unable to write manifest JSON. Error: %v", err) + return + } +} + func StaticRedirect(target string) func(w http.ResponseWriter, req *http.Request) { return func(w http.ResponseWriter, req *http.Request) { http.Redirect(w, req, path.Join(setting.StaticURLPrefix, target), http.StatusMovedPermanently) diff --git a/routers/web/web.go b/routers/web/web.go index 4e10dcc67b..1485579b1d 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1,5 +1,5 @@ // Copyright 2017 The Gitea Authors. All rights reserved. -// Copyright 2023 The Forgejo Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. All rights reserved. // SPDX-License-Identifier: MIT package web @@ -278,6 +278,7 @@ func Routes() *web.Route { } routes.Methods("GET,HEAD", "/robots.txt", append(mid, misc.RobotsTxt)...) + routes.Methods("GET,HEAD", "/manifest.json", append(mid, misc.ManifestJSON)...) routes.Get("/ssh_info", misc.SSHInfo) routes.Get("/api/healthz", healthcheck.Check) diff --git a/services/context/context.go b/services/context/context.go index c8a3a71a2e..6c83ca5d02 100644 --- a/services/context/context.go +++ b/services/context/context.go @@ -1,5 +1,6 @@ // Copyright 2014 The Gogs Authors. All rights reserved. // Copyright 2020 The Gitea Authors. All rights reserved. +// Copyright 2024 The Forgejo Authors. // SPDX-License-Identifier: MIT package context @@ -184,8 +185,6 @@ func Contexter() func(next http.Handler) http.Handler { ctx.Data["DisableForks"] = setting.Repository.DisableForks ctx.Data["EnableActions"] = setting.Actions.Enabled - ctx.Data["ManifestData"] = setting.ManifestData - ctx.Data["UnitWikiGlobalDisabled"] = unit.TypeWiki.UnitGlobalDisabled() ctx.Data["UnitIssuesGlobalDisabled"] = unit.TypeIssues.UnitGlobalDisabled() ctx.Data["UnitPullsGlobalDisabled"] = unit.TypePullRequests.UnitGlobalDisabled() diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl index af1467fb5c..12b41ac922 100644 --- a/templates/base/head.tmpl +++ b/templates/base/head.tmpl @@ -4,7 +4,7 @@ {{/* Display `- .Repository.FullName` only if `.Title` does not already start with that. */}} {{if .Title}}{{.Title}} - {{end}}{{if and (.Repository.Name) (not (StringUtils.HasPrefix .Title .Repository.FullName))}}{{.Repository.FullName}} - {{end}}{{AppDisplayName}} - {{if .ManifestData}}{{end}} + diff --git a/tests/integration/web_misc_test.go b/tests/integration/web_misc_test.go new file mode 100644 index 0000000000..15b5e05fbd --- /dev/null +++ b/tests/integration/web_misc_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package integration + +import ( + "net/http" + "os" + "testing" + + "forgejo.org/modules/setting" + "forgejo.org/modules/test" + "forgejo.org/modules/util" + "forgejo.org/tests" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManifestJson(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/manifest.json") + resp := session.MakeRequest(t, req, http.StatusOK) + + data := make(map[string]any) + DecodeJSON(t, resp, &data) + + assert.Equal(t, setting.AppName, data["name"]) + assert.Equal(t, setting.AppName, data["short_name"]) + assert.Equal(t, setting.AppURL, data["start_url"]) + assert.NotContains(t, data, "display") +} + +func TestManifestJsonStandalone(t *testing.T) { + defer tests.PrepareTestEnv(t)() + defer test.MockVariableValue(&setting.PWA.Standalone, true)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/manifest.json") + resp := session.MakeRequest(t, req, http.StatusOK) + + data := make(map[string]any) + DecodeJSON(t, resp, &data) + + assert.Equal(t, setting.AppName, data["name"]) + assert.Equal(t, setting.AppName, data["short_name"]) + assert.Equal(t, setting.AppURL, data["start_url"]) + assert.Contains(t, data, "display") + assert.Equal(t, "standalone", data["display"]) +} + +func TestManifestJsonCustomFile(t *testing.T) { + require.NoError(t, os.MkdirAll(util.FilePathJoinAbs(setting.CustomPath, "public"), 0o777)) + manifestPath := util.FilePathJoinAbs(setting.CustomPath, "public/manifest.json") + file, err := os.OpenFile(manifestPath, os.O_CREATE|os.O_RDWR, 0o777) + require.NoError(t, err) + _, err = file.Write([]byte(`{"name":"MyCustomJson"}`)) + require.NoError(t, err) + require.NoError(t, file.Close()) + defer os.Remove(manifestPath) + + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user2") + req := NewRequest(t, "GET", "/manifest.json") + resp := session.MakeRequest(t, req, http.StatusOK) + + data := make(map[string]any) + DecodeJSON(t, resp, &data) + + assert.Equal(t, "MyCustomJson", data["name"]) + assert.NotContains(t, data, "short_name") + assert.NotContains(t, data, "start_url") + assert.NotContains(t, data, "display") +}