diff --git a/assets/index.html b/assets/index.html index 2f59fb386..081b376b6 100644 --- a/assets/index.html +++ b/assets/index.html @@ -25,14 +25,7 @@ - +
diff --git a/assets/js/app.ts b/assets/js/app.ts index 169870187..b453c8527 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -104,7 +104,7 @@ window.app = app.mount("#app"); watchThemeChanges(); appDetection(router); -if (window.evcc.customCss === "true") { +if (window.evcc?.customCss) { const link = document.createElement("link"); link.href = `./custom.css`; link.rel = "stylesheet"; diff --git a/assets/js/components/AboutModal.vue b/assets/js/components/AboutModal.vue index 53ae62b41..e6469e17c 100644 --- a/assets/js/components/AboutModal.vue +++ b/assets/js/components/AboutModal.vue @@ -1,9 +1,21 @@ @@ -52,6 +53,7 @@ export default defineComponent({ installed: String, commit: String, availableVersion: String, + customBrand: String, }, data() { return { open: false }; diff --git a/assets/js/components/BottomTabs/MoreMenu.vue b/assets/js/components/BottomTabs/MoreMenu.vue index 2ed8516d0..22d32d82d 100644 --- a/assets/js/components/BottomTabs/MoreMenu.vue +++ b/assets/js/components/BottomTabs/MoreMenu.vue @@ -12,7 +12,7 @@ @click="openAboutModal" > - evcc + {{ customBrand || "evcc" }} {{ versionLabel }} + {{ $t("help.contactButton") }} + + - + @@ -74,6 +74,15 @@ export default defineComponent({ version() { return store.state.version; }, + custom() { + return { + logo: window.evcc?.customLogo ?? false, + brand: window.evcc?.customBrand ?? "", + website: window.evcc?.customWebsite ?? "", + email: window.evcc?.customEmail ?? "", + phone: window.evcc?.customPhone ?? "", + }; + }, showRoutes() { return this.state.startupCompleted; }, @@ -95,15 +104,21 @@ export default defineComponent({ }, aboutModalProps() { return { - installed: window.evcc.version, - commit: window.evcc.commit, + installed: window.evcc?.version, + commit: window.evcc?.commit, + customLogo: this.custom.logo, + customBrand: this.custom.brand, + customWebsite: this.custom.website, + customEmail: this.custom.email, + customPhone: this.custom.phone, ...this.collectProps(AboutModal, this.state), }; }, bottomTabBarProps() { return { - installed: window.evcc.version, - commit: window.evcc.commit, + installed: window.evcc?.version, + commit: window.evcc?.commit, + customBrand: this.custom.brand, ...this.collectProps(BottomTabBar, this.state), }; }, diff --git a/cmd/flags.go b/cmd/flags.go index 2d6a33e04..827834c37 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -28,6 +28,24 @@ const ( flagCustomCss = "custom-css" flagCustomCssDescription = "Additional user-defined CSS file for custom styling. No compatibility guarantees." + flagCustomLogoLight = "custom-logo-light" + flagCustomLogoLightDescription = "Custom light-mode logo file (svg, png) replacing the evcc logo in the UI. Requires custom-logo-dark." + + flagCustomLogoDark = "custom-logo-dark" + flagCustomLogoDarkDescription = "Custom dark-mode logo file (svg, png) replacing the evcc logo in the UI. Requires custom-logo-light." + + flagCustomBrand = "custom-brand" + flagCustomBrandDescription = "Custom brand name shown in the UI" + + flagCustomWebsite = "custom-website" + flagCustomWebsiteDescription = "Custom website URL shown in the UI" + + flagCustomEmail = "custom-email" + flagCustomEmailDescription = "Support email address shown in the UI" + + flagCustomPhone = "custom-phone" + flagCustomPhoneDescription = "Support phone number shown in the UI" + flagBatteryMode = "battery-mode" flagBatteryModeDescription = "Set battery mode (normal, hold, charge, holdcharge)" flagBatteryModeWait = "battery-mode-wait" diff --git a/cmd/root.go b/cmd/root.go index c005de6e2..90ff5cf9f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -49,7 +49,7 @@ var ( log = util.NewLogger("main") cfgFile string cfgDatabase string - customCssFile string + customization server.Customization ignoreEmpty = "" // ignore empty keys ignoreLogs = []string{"log"} // ignore log messages, including warn/error ignoreMqtt = []string{"log", "auth", "releaseNotes"} // excessive size may crash certain brokers @@ -104,7 +104,15 @@ func init() { rootCmd.Flags().Bool(flagDisableAuth, false, flagDisableAuthDescription) rootCmd.Flags().Bool(flagDemoMode, false, flagDemoModeDescription) - rootCmd.Flags().StringVar(&customCssFile, flagCustomCss, "", flagCustomCssDescription) + + // UI customization, flags default to environment variables + rootCmd.Flags().StringVar(&customization.Css, flagCustomCss, os.Getenv("EVCC_CUSTOM_CSS"), flagCustomCssDescription) + rootCmd.Flags().StringVar(&customization.LogoLight, flagCustomLogoLight, os.Getenv("EVCC_CUSTOM_LOGO_LIGHT"), flagCustomLogoLightDescription) + rootCmd.Flags().StringVar(&customization.LogoDark, flagCustomLogoDark, os.Getenv("EVCC_CUSTOM_LOGO_DARK"), flagCustomLogoDarkDescription) + rootCmd.Flags().StringVar(&customization.Brand, flagCustomBrand, os.Getenv("EVCC_CUSTOM_BRAND"), flagCustomBrandDescription) + rootCmd.Flags().StringVar(&customization.Website, flagCustomWebsite, os.Getenv("EVCC_CUSTOM_WEBSITE"), flagCustomWebsiteDescription) + rootCmd.Flags().StringVar(&customization.Email, flagCustomEmail, os.Getenv("EVCC_CUSTOM_EMAIL"), flagCustomEmailDescription) + rootCmd.Flags().StringVar(&customization.Phone, flagCustomPhone, os.Getenv("EVCC_CUSTOM_PHONE"), flagCustomPhoneDescription) } // initConfig reads in config file and ENV variables if set @@ -242,7 +250,7 @@ func runRoot(cmd *cobra.Command, args []string) { // create web server socketHub := server.NewSocketHub() - httpd := server.NewHTTPd(fmt.Sprintf(":%d", conf.Network.Port), socketHub, customCssFile) + httpd := server.NewHTTPd(fmt.Sprintf(":%d", conf.Network.Port), socketHub, customization) // start serving in background, watch for “routine‐only” errors go func() { diff --git a/i18n/de.json b/i18n/de.json index 8099320dd..26b17450c 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1099,6 +1099,8 @@ "version": { "availableLong": "neue Version verfügbar", "community": "evcc Community", + "labelEmail": "E-Mail", + "labelPhone": "Telefon", "labelRelease": "Release", "labelVersion": "Version", "labelWebsite": "Website", @@ -1181,6 +1183,7 @@ "sessions": "Ladevorgänge" }, "help": { + "contactButton": "Kontakt aufnehmen", "discussionsButton": "GitHub Discussions", "documentationButton": "Dokumentation", "issueButton": "Problem melden", diff --git a/i18n/en.json b/i18n/en.json index a031c1684..3877829df 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1099,6 +1099,8 @@ "version": { "availableLong": "new version available", "community": "evcc community", + "labelEmail": "Email", + "labelPhone": "Phone", "labelRelease": "Release", "labelVersion": "Version", "labelWebsite": "Website", @@ -1181,6 +1183,7 @@ "sessions": "Charging Sessions" }, "help": { + "contactButton": "Contact us", "discussionsButton": "GitHub discussions", "documentationButton": "Documentation", "issueButton": "Report a problem", diff --git a/server/http.go b/server/http.go index b1c54e3c3..b1aa3f0de 100644 --- a/server/http.go +++ b/server/http.go @@ -46,8 +46,19 @@ type HTTPd struct { *http.Server } +// Customization contains white-label settings for the UI +type Customization struct { + Css string + LogoLight string + LogoDark string + Brand string + Website string + Email string + Phone string +} + // NewHTTPd creates HTTP server with configured routes for loadpoint -func NewHTTPd(addr string, hub *SocketHub, customCssFile string) *HTTPd { +func NewHTTPd(addr string, hub *SocketHub, custom Customization) *HTTPd { router := mux.NewRouter().StrictSlash(true) log := util.NewLogger("httpd") @@ -79,21 +90,44 @@ func NewHTTPd(addr string, hub *SocketHub, customCssFile string) *HTTPd { }) }) - if customCssFile != "" { - log.WARN.Printf("❗ using custom CSS: %s", customCssFile) - if _, err := os.Stat(customCssFile); os.IsNotExist(err) { - log.FATAL.Fatalf("custom CSS file does not exist: %s", customCssFile) - } - static.HandleFunc("/custom.css", func(w http.ResponseWriter, r *http.Request) { + serveCustomFile := func(path, file string) { + static.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { // disable caching w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") - http.ServeFile(w, r, customCssFile) + http.ServeFile(w, r, file) }) } - static.HandleFunc("/", indexHandler(customCssFile != "")) + if custom.Css != "" { + log.WARN.Printf("❗ using custom CSS: %s", custom.Css) + if _, err := os.Stat(custom.Css); os.IsNotExist(err) { + log.FATAL.Fatalf("custom CSS file does not exist: %s", custom.Css) + } + serveCustomFile("/custom.css", custom.Css) + } + + if (custom.LogoLight == "") != (custom.LogoDark == "") { + log.FATAL.Fatal("custom logo requires both light and dark variants") + } + + for path, file := range map[string]string{ + "/custom-logo-light": custom.LogoLight, + "/custom-logo-dark": custom.LogoDark, + } { + if file == "" { + continue + } + log.WARN.Printf("❗ using custom logo: %s", file) + if _, err := os.Stat(file); os.IsNotExist(err) { + log.FATAL.Fatalf("custom logo file does not exist: %s", file) + } + serveCustomFile(path, file) + } + + static.HandleFunc("/globals.js", globalsJsHandler(custom)) + static.HandleFunc("/", indexHandler()) for _, dir := range []string{"assets", "meta"} { static.PathPrefix("/" + dir).Handler(http.FileServer(http.FS(assets.Web))) } diff --git a/server/http_site_handler.go b/server/http_site_handler.go index ba64a0e26..1b5d70757 100644 --- a/server/http_site_handler.go +++ b/server/http_site_handler.go @@ -41,7 +41,42 @@ func getPreferredLanguage(header string) string { return base.String() } -func indexHandler(customCss bool) http.HandlerFunc { +// globalsJsHandler serves version and ui customization as window.evcc globals +func globalsJsHandler(custom Customization) http.HandlerFunc { + globals := struct { + Version string `json:"version"` + Commit string `json:"commit"` + CustomCss bool `json:"customCss"` + CustomLogo bool `json:"customLogo"` + Brand string `json:"customBrand"` + Website string `json:"customWebsite"` + Email string `json:"customEmail"` + Phone string `json:"customPhone"` + }{ + Version: util.Version, + Commit: util.Commit, + CustomCss: custom.Css != "", + CustomLogo: custom.LogoLight != "", + Brand: custom.Brand, + Website: custom.Website, + Email: custom.Email, + Phone: custom.Phone, + } + + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/javascript; charset=UTF-8") + w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") + + if _, err := w.Write([]byte("window.evcc = ")); err != nil { + return + } + if err := json.NewEncoder(w).Encode(globals); err != nil { + log.ERROR.Println("httpd: failed to render globals:", err.Error()) + } + } +} + +func indexHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=UTF-8") w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") @@ -64,9 +99,7 @@ func indexHandler(customCss bool) http.HandlerFunc { if err := t.Execute(w, map[string]any{ "Version": util.Version, - "Commit": util.Commit, "DefaultLang": defaultLang, - "CustomCss": customCss, }); err != nil { log.ERROR.Println("httpd: failed to render main page:", err.Error()) } diff --git a/server/http_test.go b/server/http_test.go index f58b8c587..2a1a422aa 100644 --- a/server/http_test.go +++ b/server/http_test.go @@ -15,7 +15,7 @@ import ( func TestWriteTimeout(t *testing.T) { timeout := 200 * time.Millisecond - srv := NewHTTPd("", nil, "") + srv := NewHTTPd("", nil, Customization{}) srv.WriteTimeout = timeout srv.Router().HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) { diff --git a/tests/custom-logo-dark.svg b/tests/custom-logo-dark.svg new file mode 100644 index 000000000..836696c6e --- /dev/null +++ b/tests/custom-logo-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/custom-logo-light.svg b/tests/custom-logo-light.svg new file mode 100644 index 000000000..1bfd4dda9 --- /dev/null +++ b/tests/custom-logo-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/customization.spec.ts b/tests/customization.spec.ts new file mode 100644 index 000000000..f62b9d15b --- /dev/null +++ b/tests/customization.spec.ts @@ -0,0 +1,69 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, baseUrl } from "./evcc"; +import { openMoreMenu } from "./utils"; + +test.use({ baseURL: baseUrl() }); + +test.beforeAll(async () => { + await start("basics.evcc.yaml", null, [ + "--disable-auth", + "--custom-brand", + "G1GA HEMS", + "--custom-logo-light", + "tests/custom-logo-light.svg", + "--custom-logo-dark", + "tests/custom-logo-dark.svg", + "--custom-website", + "https://example.com/hems", + "--custom-email", + "support@example.com", + "--custom-phone", + "+49 123 456789", + ]); +}); + +test.afterAll(async () => { + await stop(); +}); + +test.describe("customization", async () => { + test("more menu shows custom brand", async ({ page }) => { + await page.goto("/"); + const menu = await openMoreMenu(page); + await expect(menu.getByRole("button", { name: /G1GA HEMS/ })).toBeVisible(); + }); + + test("about modal shows custom logo and contact info", async ({ page }) => { + await page.goto("/"); + const menu = await openMoreMenu(page); + await menu.getByRole("button", { name: /G1GA HEMS/ }).click(); + + // only the theme-matching logo variant is visible + const logo = page.getByRole("img", { name: "G1GA HEMS" }); + await expect(logo).toBeVisible(); + await expect(logo).toHaveAttribute("src", "./custom-logo-light"); + + const website = page.getByRole("link", { name: "example.com", exact: true }); + await expect(website).toBeVisible(); + await expect(website).toHaveAttribute("href", "https://example.com/hems"); + + const phone = page.getByRole("link", { name: "+49 123 456789" }); + await expect(phone).toBeVisible(); + await expect(phone).toHaveAttribute("href", "tel:+49123456789"); + + const email = page.getByRole("link", { name: "support@example.com" }); + await expect(email).toBeVisible(); + await expect(email).toHaveAttribute("href", "mailto:support@example.com"); + }); + + test("help modal shows contact button instead of github discussions", async ({ page }) => { + await page.goto("/"); + const menu = await openMoreMenu(page); + await menu.getByRole("button", { name: "Need Help?" }).click(); + + const contact = page.getByRole("link", { name: "Contact us" }); + await expect(contact).toBeVisible(); + await expect(contact).toHaveAttribute("href", "mailto:support@example.com"); + await expect(page.getByRole("link", { name: "GitHub discussions" })).not.toBeVisible(); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 58745b96b..9709c3bf0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,9 @@ import browserslist from "browserslist"; import { visualizer } from "rollup-plugin-visualizer"; import path from "path"; +const frontendPort = Number(process.env.VITE_PORT) || 7071; +const backendUrl = `http://localhost:${Number(process.env.VITE_BACKEND_PORT) || 7070}`; + export default defineConfig({ staged: { "*": "vp check --fix", @@ -74,19 +77,19 @@ export default defineConfig({ assetsInlineLimit: 1024, chunkSizeWarningLimit: 800, // legacy build increases file size }, - server: (() => { - const frontend = Number(process.env.VITE_PORT) || 7071; - const backend = Number(process.env.VITE_BACKEND_PORT) || 7070; - return { - port: frontend, - proxy: { - "/api": `http://localhost:${backend}`, - "/i18n": `http://localhost:${backend}`, - "/providerauth": `http://localhost:${backend}`, - "/ws": { target: `ws://localhost:${backend}`, ws: true }, - }, - }; - })(), + server: { + port: frontendPort, + proxy: { + "/api": backendUrl, + "/i18n": backendUrl, + "/providerauth": backendUrl, + "/globals.js": backendUrl, + "/custom.css": backendUrl, + "/custom-logo-light": backendUrl, + "/custom-logo-dark": backendUrl, + "/ws": { target: backendUrl.replace("http", "ws"), ws: true }, + }, + }, plugins: lazyPlugins(() => [ legacy({ modernPolyfills: ["es.promise.all-settled"],