Skip to content

[Feature] Add PWA Push notifications and Shortcuts#29072

Draft
webalexeu wants to merge 1 commit intoevcc-io:masterfrom
webalexeu:feat/pwa_notifications
Draft

[Feature] Add PWA Push notifications and Shortcuts#29072
webalexeu wants to merge 1 commit intoevcc-io:masterfrom
webalexeu:feat/pwa_notifications

Conversation

@webalexeu
Copy link
Copy Markdown

Improvement on the Progressive Web App by adding push notifications (Fixes #29071)

Summary

  • Web Push notifications: evcc can now send push notifications to browsers/devices that have the PWA installed. Notifications are delivered via the Web Push protocol (VAPID) and work on Android, iOS 16.4+, and desktop browsers.
  • Automatic subscription: the service worker registers and subscribes silently on page load — no settings toggle required. The browser permission prompt appears once on first visit.
  • PWA shortcuts: the installed PWA now exposes quick-launch shortcuts (Configuration, Charging Sessions, Logs) in the OS long-press menu, with names translated via the existing i18n system based on the browser's Accept-Language header.

Changes

Backend

  • server/push/vapid.go — VAPID key pair generation, persisted in the settings DB so keys survive restarts
  • server/push/subscription.go — GORM model for browser push subscriptions (endpoint, auth, p256dh, user agent), with auto-migration via db.Register
  • server/push/sender.goapi.Messenger implementation; sends push payloads to all subscriptions, auto-removes stale endpoints on FCM 410/404
  • server/http_push_handler.go — three API endpoints: GET /api/push/vapidkey, GET /api/push/check, POST /api/push/subscribe
  • server/http.go — registers push API routes and /sw.js (no-cache, root scope); registers /meta/site.webmanifest before the generic file server
  • server/http_site_handler.gowebmanifestHandler serves the manifest as a Go template with i18n values injected; i18nLookup helper with English fallback
  • cmd/setup.go — unconditionally registers push.NewSender() in the message hub (zero overhead when no subscriptions)

Frontend

  • assets/public/sw.js — service worker handling push, pushsubscriptionchange (endpoint rotation), and notificationclick events
  • assets/js/utils/push.tsregisterServiceWorker(): registers the SW, checks existing subscription against the backend, requests permission, fetches VAPID key, subscribes, and saves to backend
  • assets/js/app.ts — calls registerServiceWorker() on startup
  • assets/index.html — adds mobile-web-app-capable meta tag

Manifest

  • assets/public/meta/site.webmanifest — adds three PWA shortcuts (Configuration, Charging Sessions, Logs) with i18n names via [[.Title]] template placeholders; fixes pre-existing "purpose:" typo on SVG icon entries

Zero-overhead design

The push sender returns immediately if no subscriptions are registered — no VAPID key load, no DB query for payloads beyond a single COUNT. Adding it unconditionally to the hub requires no configuration flag.

Dependencies

Thanks for this great product

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • The i18nLookup helper re-reads and unmarshals the i18n JSON file on every manifest request and lookup; consider caching the parsed language maps (or at least the raw file bytes) to avoid repeated disk I/O and JSON decoding on each /meta/site.webmanifest call.
  • In sw.js, the pushsubscriptionchange handler assumes event.oldSubscription (and its .options) is always present and does not send userAgent like the initial subscription path; it may be worth hardening this flow (null checks, try/catch around subscribe and fetch, and consistent payload structure) to avoid silent failures or inconsistent records.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `i18nLookup` helper re-reads and unmarshals the i18n JSON file on every manifest request and lookup; consider caching the parsed language maps (or at least the raw file bytes) to avoid repeated disk I/O and JSON decoding on each `/meta/site.webmanifest` call.
- In `sw.js`, the `pushsubscriptionchange` handler assumes `event.oldSubscription` (and its `.options`) is always present and does not send `userAgent` like the initial subscription path; it may be worth hardening this flow (null checks, try/catch around `subscribe` and `fetch`, and consistent payload structure) to avoid silent failures or inconsistent records.

## Individual Comments

### Comment 1
<location path="server/http.go" line_range="272" />
<code_context>
+
+		push.Methods(http.MethodGet).Path("/vapidkey").Handler(pushVapidKeyHandler())
+		push.Methods(http.MethodGet).Path("/check").Handler(pushCheckHandler())
+		push.Methods(http.MethodPost, http.MethodOptions).Path("/subscribe").Handler(pushSubscribeHandler())
+	}
+
</code_context>
<issue_to_address>
**issue:** OPTIONS preflight is routed to a handler that expects a JSON body, likely returning 400 on preflight

`pushSubscribeHandler` is used for both POST and OPTIONS, but it always tries to decode a JSON body and returns 400 on errors. For CORS preflight or other body-less OPTIONS requests, this will likely fail the subscription flow. Either rely on gorilla’s default OPTIONS handling by removing `http.MethodOptions` here, or short-circuit OPTIONS in `pushSubscribeHandler` (e.g., return 204 before attempting to decode the body).
</issue_to_address>

### Comment 2
<location path="assets/public/sw.js" line_range="24-26" />
<code_context>
+});
+
+// Handle browser-initiated subscription rotation (endpoint expiry).
+self.addEventListener("pushsubscriptionchange", (event) => {
+  event.waitUntil(
+    self.registration.pushManager
+      .subscribe(event.oldSubscription.options)
+      .then((subscription) => {
</code_context>
<issue_to_address>
**issue (bug_risk):** pushsubscriptionchange handler assumes oldSubscription/options are always present

`event.oldSubscription` (and its `options` property) isn’t guaranteed to be defined across browsers, so this can throw at runtime. Please guard access to `oldSubscription.options` and either fall back to a sane default (e.g. `{ userVisibleOnly: true, applicationServerKey: ... }`) or skip resubscribe if options are unavailable.
</issue_to_address>

### Comment 3
<location path="server/push/vapid.go" line_range="29-32" />
<code_context>
+		return "", "", err
+	}
+
+	settings.SetString(keyVAPIDPrivate, private)
+	settings.SetString(keyVAPIDPublic, public)
+
+	return private, public, nil
+}
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Errors from persisting VAPID keys are ignored, which can hide configuration/storage issues

Both `settings.SetString` calls ignore their errors. If persistence fails (e.g. storage/DB issues), you still return the in-memory keys, and a restart may generate a new pair and break existing subscriptions. At minimum, log these errors or propagate them so callers can decide how to handle failure.

Suggested implementation:

```golang
	if err := settings.SetString(keyVAPIDPrivate, private); err != nil {
		return "", "", fmt.Errorf("persisting VAPID private key: %w", err)
	}
	if err := settings.SetString(keyVAPIDPublic, public); err != nil {
		return "", "", fmt.Errorf("persisting VAPID public key: %w", err)
	}

	return private, public, nil
}

```

- Ensure `fmt` is imported at the top of `server/push/vapid.go` if it is not already:
  - `import "fmt"`
- This change assumes `settings.SetString` returns an `error`. If its signature is different, adjust the error handling accordingly.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread server/http.go Outdated
Comment thread assets/public/sw.js
Comment thread server/push/vapid.go
Comment on lines +29 to +32
settings.SetString(keyVAPIDPrivate, private)
settings.SetString(keyVAPIDPublic, public)

return private, public, nil
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Errors from persisting VAPID keys are ignored, which can hide configuration/storage issues

Both settings.SetString calls ignore their errors. If persistence fails (e.g. storage/DB issues), you still return the in-memory keys, and a restart may generate a new pair and break existing subscriptions. At minimum, log these errors or propagate them so callers can decide how to handle failure.

Suggested implementation:

	if err := settings.SetString(keyVAPIDPrivate, private); err != nil {
		return "", "", fmt.Errorf("persisting VAPID private key: %w", err)
	}
	if err := settings.SetString(keyVAPIDPublic, public); err != nil {
		return "", "", fmt.Errorf("persisting VAPID public key: %w", err)
	}

	return private, public, nil
}
  • Ensure fmt is imported at the top of server/push/vapid.go if it is not already:
    • import "fmt"
  • This change assumes settings.SetString returns an error. If its signature is different, adjust the error handling accordingly.

@webalexeu webalexeu marked this pull request as draft April 13, 2026 09:41
@webalexeu webalexeu force-pushed the feat/pwa_notifications branch 2 times, most recently from b771371 to 915d898 Compare April 13, 2026 09:52
@andig andig added the ux User experience/ interface label Apr 13, 2026
Comment thread cmd/setup.go
}

// Always add the Web Push (PWA) sender — it sends to browser-subscribed clients.
messageHub.Add(push.NewSender())
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not convinced this is something we'd want. At least not without explicit opt-in.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not convinced this is something we'd want. At least not without explicit opt-in.
You can control the opt-in on app side by default on PWA
Not sure it is relevant to control it on evcc side, what do you think ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dunno- would it interfere with browser users?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dunno- would it interfere with browser users?

Well, that's the same flag for browser or pwa if I'm not mistaken

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You prefer to enable this a notification service where user need to opt-in explicitely ?
I try to compare with other products and I think they all keep usually control within app

@webalexeu webalexeu force-pushed the feat/pwa_notifications branch 2 times, most recently from 04185ac to 1219c4a Compare April 13, 2026 10:45
@webalexeu webalexeu marked this pull request as ready for review April 13, 2026 11:25
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues, and left some high level feedback:

  • In registerServiceWorker, the /api/push/check call is not wrapped in a try/catch; if the network request fails it will throw and abort the rest of the logic, so consider catching errors there to avoid silently skipping (or unnecessarily dropping) existing subscriptions in transient failure cases.
  • In webmanifestHandler, a template parse error calls log.FATAL.Fatal, which will terminate the process; it would be safer to log the error and return a 5xx response so a malformed manifest template does not bring down the entire server.
  • The Sender.Send implementation loads all push subscriptions into memory in one go; if the number of subscriptions grows large, consider iterating in batches or with a streaming approach to avoid potential memory and latency issues on large installations.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `registerServiceWorker`, the `/api/push/check` call is not wrapped in a try/catch; if the network request fails it will throw and abort the rest of the logic, so consider catching errors there to avoid silently skipping (or unnecessarily dropping) existing subscriptions in transient failure cases.
- In `webmanifestHandler`, a template parse error calls `log.FATAL.Fatal`, which will terminate the process; it would be safer to log the error and return a 5xx response so a malformed manifest template does not bring down the entire server.
- The `Sender.Send` implementation loads all push subscriptions into memory in one go; if the number of subscriptions grows large, consider iterating in batches or with a streaming approach to avoid potential memory and latency issues on large installations.

## Individual Comments

### Comment 1
<location path="assets/js/utils/push.ts" line_range="49" />
<code_context>
+  }
+
+  // Request notification permission (shows browser prompt if not yet decided).
+  const permission = await Notification.requestPermission();
+  if (permission !== "granted") return;
+
</code_context>
<issue_to_address>
**issue:** Guard `Notification.requestPermission` with a feature check to avoid runtime errors in environments without the Notification API.

In some environments (older browsers, embedded webviews) `Notification` can be undefined, causing a `ReferenceError` here. Since you already feature-check `serviceWorker` and `PushManager`, please also guard this with a check like `"Notification" in window` (or equivalent) and return early if notifications aren’t supported.
</issue_to_address>

### Comment 2
<location path="server/push/subscription.go" line_range="29-38" />
<code_context>
+// AllSubscriptions returns all stored push subscriptions.
+func AllSubscriptions() ([]Subscription, error) {
+	var subs []Subscription
+	if db.Instance == nil {
+		return nil, nil
+	}
+	return subs, db.Instance.Find(&subs).Error
+}
+
+// SubscriptionExists returns true if an endpoint is registered in the DB.
+func SubscriptionExists(endpoint string) (bool, error) {
+	if db.Instance == nil {
+		return false, nil
+	}
+	var count int64
</code_context>
<issue_to_address>
**issue (bug_risk):** `SubscriptionExists` returning false when DB is uninitialized can desync client subscriptions.

When `db.Instance` is nil, `SubscriptionExists` returns `false, nil`, causing `pushCheckHandler` to reply 404. The frontend treats this as “subscription not known” and will unsubscribe/resubscribe, even though the issue is backend state. Consider returning an error here (like `SaveSubscription`/`DeleteSubscription`) so the check endpoint can return 5xx instead of triggering unnecessary subscription churn.
</issue_to_address>

### Comment 3
<location path="server/push/vapid.go" line_range="29-30" />
<code_context>
+		return "", "", err
+	}
+
+	settings.SetString(keyVAPIDPrivate, private)
+	settings.SetString(keyVAPIDPublic, public)
+
+	return private, public, nil
</code_context>
<issue_to_address>
**issue (bug_risk):** Ignored errors when persisting VAPID keys can lead to inconsistent keys between restarts.

If a `SetString` call fails, the function still returns the new keys, but they won’t be persisted. On the next restart a new pair will be generated, breaking existing subscriptions. Please handle the `SetString` errors explicitly—either return an error so callers avoid using non-persisted keys, or at minimum log the failure and document that keys may change if persistence fails.
</issue_to_address>

### Comment 4
<location path="server/http_site_handler.go" line_range="67-76" />
<code_context>
+
+// i18nLookup resolves a dot-separated key path from the i18n map for the given
+// language. Falls back to English if the language or key is not found.
+func i18nLookup(lang string, path ...string) string {
+	lookup := func(m map[string]any) (string, bool) {
+		if m == nil {
+			return "", false
+		}
+		var cur any = m
+		for _, key := range path {
+			node, ok := cur.(map[string]any)
+			if !ok {
+				return "", false
+			}
+			cur = node[key]
+		}
+		val, ok := cur.(string)
+		return val, ok
+	}
+
+	if val, ok := lookup(i18nMap(lang)); ok {
+		return val
+	}
+	if val, ok := lookup(i18nMap("en")); ok {
+		return val
+	}
+	return path[len(path)-1]
+}
+
</code_context>
<issue_to_address>
**nitpick:** Defensive check for empty `path` in `i18nLookup` would avoid a potential panic.

This function assumes `path` is non-empty when doing `path[len(path)-1]`. While current callers always pass at least one segment, an empty `path` in future would cause an index-out-of-range panic. Please add a guard like `if len(path) == 0 { return "" }` to make this helper safe for all callers.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread assets/js/utils/push.ts
Comment thread server/push/subscription.go Outdated
Comment thread server/push/vapid.go
Comment thread server/http_site_handler.go
@webalexeu webalexeu force-pushed the feat/pwa_notifications branch 3 times, most recently from 205a4ce to 39d8039 Compare April 13, 2026 13:50
@naltatis naltatis marked this pull request as draft April 13, 2026 18:12
- Add Web Push (VAPID) notification support: VAPID key generation/persistence,
  subscription storage (GORM), push sender integrated into message hub
- Add service worker (sw.js) handling push events, subscription rotation,
  and notification clicks
- Auto-register service worker and subscribe on page load (no UI toggle needed)
- Add API endpoints: GET /api/push/vapidkey, GET /api/push/check, POST /api/push/subscribe
- Auto-remove stale subscriptions on FCM 410/404 responses
- Serve site.webmanifest as a template with i18n shortcut names (config,
  sessions, log) resolved from Accept-Language header; cache parsed maps
- Add PWA shortcuts for Configuration, Charging Sessions, and Logs
- Fix "purpose:" typo in webmanifest icon entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@webalexeu webalexeu force-pushed the feat/pwa_notifications branch from 39d8039 to 728f980 Compare April 16, 2026 22:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ux User experience/ interface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add push notifications when using Progressive Web App

2 participants