From 157df7450833609cfc91f3a0804e9834fc9e038d Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 22 Jul 2024 14:39:15 -0600 Subject: [PATCH] Implement support for notifications (#12523) * Setup basic notification page * Add basic notification implementation * Register for push notifications * Implement dispatching * Add fields * Handle image and link * Add notification config * Add field for users notification tokens * Implement saving of notification tokens * Implement VAPID key generation * Implement public key encoding * Implement webpush from server * Implement push notification handling * Make notifications config only * Add maskable icon * Use zod form to control notification settings in the UI * Use js * Always open notification * Support multiple endpoints * Handle cleaning up expired notification registrations * Correctly unsubscribe notifications * Change ttl dynamically * Add note about notification latency and features * Cleanup docs * Fix firefox pushes * Add links to docs and improve formatting * Improve wording * Fix docstring Co-authored-by: Blake Blackshear * Handle case where native auth is not enabled * Show errors in UI --------- Co-authored-by: Blake Blackshear --- docker/main/requirements-wheels.txt | 5 +- docs/docs/configuration/notifications.md | 42 +++ docs/docs/configuration/reference.md | 12 +- docs/sidebars.js | 1 + frigate/api/app.py | 2 + frigate/api/notification.py | 65 ++++ frigate/app.py | 4 + frigate/comms/webpush.py | 189 ++++++++++ frigate/config.py | 8 + frigate/models.py | 1 + migrations/026_add_notification_tokens.py | 40 ++ package-lock.json | 6 + web/public/images/maskable-badge.png | Bin 0 -> 4965 bytes web/public/notifications-worker.js | 39 ++ web/site.webmanifest | 6 + web/src/pages/Settings.tsx | 38 +- web/src/types/frigateConfig.ts | 7 +- .../settings/NotificationsSettingsView.tsx | 344 ++++++++++++++++++ 18 files changed, 795 insertions(+), 14 deletions(-) create mode 100644 docs/docs/configuration/notifications.md create mode 100644 frigate/api/notification.py create mode 100644 frigate/comms/webpush.py create mode 100644 migrations/026_add_notification_tokens.py create mode 100644 package-lock.json create mode 100644 web/public/images/maskable-badge.png create mode 100644 web/public/notifications-worker.js create mode 100644 web/src/views/settings/NotificationsSettingsView.tsx diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index 4b4e13850..f07dc149d 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -36,4 +36,7 @@ chromadb == 0.5.0 # Generative AI google-generativeai == 0.6.* ollama == 0.2.* -openai == 1.30.* \ No newline at end of file +openai == 1.30.* +# push notifications +py-vapid == 1.9.* +pywebpush == 2.0.* \ No newline at end of file diff --git a/docs/docs/configuration/notifications.md b/docs/docs/configuration/notifications.md new file mode 100644 index 000000000..9225ea6e8 --- /dev/null +++ b/docs/docs/configuration/notifications.md @@ -0,0 +1,42 @@ +--- +id: notifications +title: Notifications +--- + +# Notifications + +Frigate offers native notifications using the [WebPush Protocol](https://web.dev/articles/push-notifications-web-push-protocol) which uses the [VAPID spec](https://tools.ietf.org/html/draft-thomson-webpush-vapid) to deliver notifications to web apps using encryption. + +## Setting up Notifications + +In order to use notifications the following requirements must be met: + +- Frigate must be accessed via a secure https connection +- A supported browser must be used. Currently Chrome, Firefox, and Safari are known to be supported. +- In order for notifications to be usable externally, Frigate must be accessible externally + +### Configuration + +To configure notifications, go to the Frigate WebUI -> Settings -> Notifications and enable, then fill out the fields and save. + +### Registration + +Once notifications are enabled, press the `Register for Notifications` button on all devices that you would like to receive notifications on. This will register the background worker. After this Frigate must be restarted and then notifications will begin to be sent. + +## Supported Notifications + +Currently notifications are only supported for review alerts. More notifications will be supported in the future. + +:::note + +Currently, only Chrome supports images in notifications. Safari and Firefox will only show a title and message in the notification. + +::: + +## Reduce Notification Latency + +Different platforms handle notifications differently, some settings changes may be required to get optimal notification delivery. + +### Android + +Most Android phones have battery optimization settings. To get reliable Notification delivery the browser (Chrome, Firefox) should have battery optimizations disabled. If Frigate is running as a PWA then the Frigate app should have battery optimizations disabled as well. \ No newline at end of file diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index fa94c98aa..8c11836b4 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -372,6 +372,14 @@ motion: # Optional: Delay when updating camera motion through MQTT from ON -> OFF (default: shown below). mqtt_off_delay: 30 +# Optional: Notification Configuration +notifications: + # Optional: Enable notification service (default: shown below) + enabled: False + # Optional: Email for push service to reach out to + # NOTE: This is required to use notifications + email: "admin@example.com" + # Optional: Record configuration # NOTE: Can be overridden at the camera level record: @@ -642,8 +650,8 @@ cameras: user: admin # Optional: password for login. password: admin - # Optional: Ignores time synchronization mismatches between the camera and the server during authentication. - # Using NTP on both ends is recommended and this should only be set to True in a "safe" environment due to the security risk it represents. + # Optional: Ignores time synchronization mismatches between the camera and the server during authentication. + # Using NTP on both ends is recommended and this should only be set to True in a "safe" environment due to the security risk it represents. ignore_time_mismatch: False # Optional: PTZ camera object autotracking. Keeps a moving object in # the center of the frame by automatically moving the PTZ camera. diff --git a/docs/sidebars.js b/docs/sidebars.js index 1e1a27046..9a6ba0df9 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -54,6 +54,7 @@ module.exports = { ], "Extra Configuration": [ "configuration/authentication", + "configuration/notifications", "configuration/hardware_acceleration", "configuration/ffmpeg_presets", "configuration/tls", diff --git a/frigate/api/app.py b/frigate/api/app.py index 0e3b0fecd..79a78f454 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -19,6 +19,7 @@ from frigate.api.auth import AuthBp, get_jwt_secret, limiter from frigate.api.event import EventBp from frigate.api.export import ExportBp from frigate.api.media import MediaBp +from frigate.api.notification import NotificationBp from frigate.api.preview import PreviewBp from frigate.api.review import ReviewBp from frigate.config import FrigateConfig @@ -48,6 +49,7 @@ bp.register_blueprint(MediaBp) bp.register_blueprint(PreviewBp) bp.register_blueprint(ReviewBp) bp.register_blueprint(AuthBp) +bp.register_blueprint(NotificationBp) def create_app( diff --git a/frigate/api/notification.py b/frigate/api/notification.py new file mode 100644 index 000000000..2fe61882f --- /dev/null +++ b/frigate/api/notification.py @@ -0,0 +1,65 @@ +"""Notification apis.""" + +import logging +import os + +from cryptography.hazmat.primitives import serialization +from flask import ( + Blueprint, + current_app, + jsonify, + make_response, + request, +) +from peewee import DoesNotExist +from py_vapid import Vapid01, utils + +from frigate.const import CONFIG_DIR +from frigate.models import User + +logger = logging.getLogger(__name__) + +NotificationBp = Blueprint("notifications", __name__) + + +@NotificationBp.route("/notifications/pubkey", methods=["GET"]) +def get_vapid_pub_key(): + if not current_app.frigate_config.notifications.enabled: + return make_response( + jsonify({"success": False, "message": "Notifications are not enabled."}), + 400, + ) + + key = Vapid01.from_file(os.path.join(CONFIG_DIR, "notifications.pem")) + raw_pub = key.public_key.public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint + ) + return jsonify(utils.b64urlencode(raw_pub)), 200 + + +@NotificationBp.route("/notifications/register", methods=["POST"]) +def register_notifications(): + if current_app.frigate_config.auth.enabled: + username = request.headers.get("remote-user", type=str) or "admin" + else: + username = "admin" + + json: dict[str, any] = request.get_json(silent=True) or {} + sub = json.get("sub") + + if not sub: + return jsonify( + {"success": False, "message": "Subscription must be provided."} + ), 400 + + try: + User.update(notification_tokens=User.notification_tokens.append(sub)).where( + User.username == username + ).execute() + return make_response( + jsonify({"success": True, "message": "Successfully saved token."}), 200 + ) + except DoesNotExist: + return make_response( + jsonify({"success": False, "message": "Could not find user."}), 404 + ) diff --git a/frigate/app.py b/frigate/app.py index ef9360354..dcc61a58c 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -25,6 +25,7 @@ from frigate.comms.config_updater import ConfigPublisher from frigate.comms.dispatcher import Communicator, Dispatcher from frigate.comms.inter_process import InterProcessCommunicator from frigate.comms.mqtt import MqttClient +from frigate.comms.webpush import WebPushClient from frigate.comms.ws import WebSocketClient from frigate.comms.zmq_proxy import ZmqProxy from frigate.config import FrigateConfig @@ -401,6 +402,9 @@ class FrigateApp: if self.config.mqtt.enabled: comms.append(MqttClient(self.config)) + if self.config.notifications.enabled: + comms.append(WebPushClient(self.config)) + comms.append(WebSocketClient(self.config)) comms.append(self.inter_process_communicator) diff --git a/frigate/comms/webpush.py b/frigate/comms/webpush.py new file mode 100644 index 000000000..084f91059 --- /dev/null +++ b/frigate/comms/webpush.py @@ -0,0 +1,189 @@ +"""Handle sending notifications for Frigate via Firebase.""" + +import datetime +import json +import logging +import os +from typing import Any, Callable + +from py_vapid import Vapid01 +from pywebpush import WebPusher + +from frigate.comms.dispatcher import Communicator +from frigate.config import FrigateConfig +from frigate.const import CONFIG_DIR +from frigate.models import User + +logger = logging.getLogger(__name__) + + +class WebPushClient(Communicator): # type: ignore[misc] + """Frigate wrapper for webpush client.""" + + def __init__(self, config: FrigateConfig) -> None: + self.config = config + self.claim_headers: dict[str, dict[str, str]] = {} + self.refresh: int = 0 + self.web_pushers: dict[str, list[WebPusher]] = {} + self.expired_subs: dict[str, list[str]] = {} + + if not self.config.notifications.email: + logger.warning("Email must be provided for push notifications to be sent.") + + # Pull keys from PEM or generate if they do not exist + self.vapid = Vapid01.from_file(os.path.join(CONFIG_DIR, "notifications.pem")) + + users: list[User] = ( + User.select(User.username, User.notification_tokens).dicts().iterator() + ) + for user in users: + self.web_pushers[user["username"]] = [] + for sub in user["notification_tokens"]: + self.web_pushers[user["username"]].append(WebPusher(sub)) + + def subscribe(self, receiver: Callable) -> None: + """Wrapper for allowing dispatcher to subscribe.""" + pass + + def check_registrations(self) -> None: + # check for valid claim or create new one + now = datetime.datetime.now().timestamp() + if len(self.claim_headers) == 0 or self.refresh < now: + self.refresh = int( + (datetime.datetime.now() + datetime.timedelta(hours=1)).timestamp() + ) + endpoints: set[str] = set() + + # get a unique set of push endpoints + for pushers in self.web_pushers.values(): + for push in pushers: + endpoint: str = push.subscription_info["endpoint"] + endpoints.add(endpoint[0 : endpoint.index("/", 10)]) + + # create new claim + for endpoint in endpoints: + claim = { + "sub": f"mailto:{self.config.notifications.email}", + "aud": endpoint, + "exp": self.refresh, + } + self.claim_headers[endpoint] = self.vapid.sign(claim) + + def cleanup_registrations(self) -> None: + # delete any expired subs + if len(self.expired_subs) > 0: + for user, expired in self.expired_subs.items(): + user_subs = [] + + # get all subscriptions, removing ones that are expired + stored_user: User = User.get_by_id(user) + for token in stored_user.notification_tokens: + if token["endpoint"] in expired: + continue + + user_subs.append(token) + + # overwrite the database and reset web pushers + User.update(notification_tokens=user_subs).where( + User.username == user + ).execute() + + self.web_pushers[user] = [] + + for sub in user_subs: + self.web_pushers[user].append(WebPusher(sub)) + + logger.info( + f"Cleaned up {len(expired)} notification subscriptions for {user}" + ) + + self.expired_subs = {} + + def publish(self, topic: str, payload: Any, retain: bool = False) -> None: + """Wrapper for publishing when client is in valid state.""" + if topic == "reviews": + self.send_alert(json.loads(payload)) + + def send_alert(self, payload: dict[str, any]) -> None: + if not self.config.notifications.email: + return + + self.check_registrations() + + # Only notify for alerts + if payload["after"]["severity"] != "alert": + return + + state = payload["type"] + + # Don't notify if message is an update and important fields don't have an update + if ( + state == "update" + and len(payload["before"]["data"]["objects"]) + == len(payload["after"]["data"]["objects"]) + and len(payload["before"]["data"]["zones"]) + == len(payload["after"]["data"]["zones"]) + ): + return + + reviewId = payload["after"]["id"] + sorted_objects: set[str] = set() + + for obj in payload["after"]["data"]["objects"]: + if "-verified" not in obj: + sorted_objects.add(obj) + + sorted_objects.update(payload["after"]["data"]["sub_labels"]) + + camera: str = payload["after"]["camera"] + title = f"{', '.join(sorted_objects).replace('_', ' ').title()}{' was' if state == 'end' else ''} detected in {', '.join(payload['after']['data']['zones']).replace('_', ' ').title()}" + message = f"Detected on {camera.replace('_', ' ').title()}" + image = f'{payload["after"]["thumb_path"].replace("/media/frigate", "")}' + + # if event is ongoing open to live view otherwise open to recordings view + direct_url = f"/review?id={reviewId}" if state == "end" else f"/#{camera}" + + for user, pushers in self.web_pushers.items(): + for pusher in pushers: + endpoint = pusher.subscription_info["endpoint"] + + # set headers for notification behavior + headers = self.claim_headers[ + endpoint[0 : endpoint.index("/", 10)] + ].copy() + headers["urgency"] = "high" + ttl = 3600 if state == "end" else 0 + + # send message + resp = pusher.send( + headers=headers, + ttl=ttl, + data=json.dumps( + { + "title": title, + "message": message, + "direct_url": direct_url, + "image": image, + "id": reviewId, + } + ), + ) + + if resp.status_code == 201: + pass + elif resp.status_code == 404 or resp.status_code == 410: + # subscription is not found or has been unsubscribed + if not self.expired_subs.get(user): + self.expired_subs[user] = [] + + self.expired_subs[user].append(pusher.subscription_info["endpoint"]) + # the subscription no longer exists and should be removed + else: + logger.warning( + f"Failed to send notification to {user} :: {resp.headers}" + ) + + self.cleanup_registrations() + + def stop(self) -> None: + pass diff --git a/frigate/config.py b/frigate/config.py index 20a540919..126d964ff 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -169,6 +169,11 @@ class AuthConfig(FrigateBaseModel): hash_iterations: int = Field(default=600000, title="Password hash iterations") +class NotificationConfig(FrigateBaseModel): + enabled: bool = Field(default=False, title="Enable notifications") + email: Optional[str] = Field(default=None, title="Email required for push.") + + class StatsConfig(FrigateBaseModel): amd_gpu_stats: bool = Field(default=True, title="Enable AMD GPU stats.") intel_gpu_stats: bool = Field(default=True, title="Enable Intel GPU stats.") @@ -1361,6 +1366,9 @@ class FrigateConfig(FrigateBaseModel): default_factory=dict, title="Frigate environment variables." ) ui: UIConfig = Field(default_factory=UIConfig, title="UI configuration.") + notifications: NotificationConfig = Field( + default_factory=NotificationConfig, title="Notification Config" + ) telemetry: TelemetryConfig = Field( default_factory=TelemetryConfig, title="Telemetry configuration." ) diff --git a/frigate/models.py b/frigate/models.py index b6588ed3b..c73033b3e 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -118,3 +118,4 @@ class RecordingsToDelete(Model): # type: ignore[misc] class User(Model): # type: ignore[misc] username = CharField(null=False, primary_key=True, max_length=30) password_hash = CharField(null=False, max_length=120) + notification_tokens = JSONField() diff --git a/migrations/026_add_notification_tokens.py b/migrations/026_add_notification_tokens.py new file mode 100644 index 000000000..37506c406 --- /dev/null +++ b/migrations/026_add_notification_tokens.py @@ -0,0 +1,40 @@ +"""Peewee migrations + +Some examples (model - class or model name):: + + > Model = migrator.orm['model_name'] # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.python(func, *args, **kwargs) # Run python code + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.drop_index(model, *col_names) + > migrator.add_not_null(model, *field_names) + > migrator.drop_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + +""" + +import peewee as pw +from playhouse.sqlite_ext import JSONField + +from frigate.models import User + +SQL = pw.SQL + + +def migrate(migrator, database, fake=False, **kwargs): + migrator.add_fields( + User, + notification_tokens=JSONField(default=[]), + ) + + +def rollback(migrator, database, fake=False, **kwargs): + pass diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..13ac760ad --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "frigate", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/web/public/images/maskable-badge.png b/web/public/images/maskable-badge.png new file mode 100644 index 0000000000000000000000000000000000000000..856f14223b08e49e96e4911de12f546b7c749d53 GIT binary patch literal 4965 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 { + // @ts-expect-error we know this exists + if (event.notification) { + // @ts-expect-error we know this exists + event.notification.close(); + + // @ts-expect-error we know this exists + if (event.notification.data) { + const url = event.notification.data.link; + // eslint-disable-next-line no-undef + if (clients.openWindow) { + // eslint-disable-next-line no-undef + return clients.openWindow(url); + } + } + } +}); diff --git a/web/site.webmanifest b/web/site.webmanifest index 53a45654b..94e455ec8 100644 --- a/web/site.webmanifest +++ b/web/site.webmanifest @@ -20,6 +20,12 @@ "sizes": "180x180", "type": "image/png", "purpose": "maskable" + }, + { + "src": "/images/maskable-badge.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "maskable" } ], "theme_color": "#ffffff", diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index c355a97c6..6d147b332 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -35,24 +35,39 @@ import ObjectSettingsView from "@/views/settings/ObjectSettingsView"; import MotionTunerView from "@/views/settings/MotionTunerView"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; import AuthenticationView from "@/views/settings/AuthenticationView"; +import NotificationView from "@/views/settings/NotificationsSettingsView"; + +const allSettingsViews = [ + "general", + "camera settings", + "masks / zones", + "motion tuner", + "debug", + "users", + "notifications", +] as const; +type SettingsType = (typeof allSettingsViews)[number]; export default function Settings() { - const settingsViews = [ - "general", - "camera settings", - "masks / zones", - "motion tuner", - "debug", - "users", - ] as const; - - type SettingsType = (typeof settingsViews)[number]; const [page, setPage] = useState("general"); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); const tabsRef = useRef(null); const { data: config } = useSWR("config"); + // available settings views + + const settingsViews = useMemo(() => { + const views = [...allSettingsViews]; + + if (!("Notification" in window) || !window.isSecureContext) { + const index = views.indexOf("notifications"); + views.splice(index, 1); + } + + return views; + }, []); + // TODO: confirm leave page const [unsavedChanges, setUnsavedChanges] = useState(false); const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); @@ -181,6 +196,9 @@ export default function Settings() { /> )} {page == "users" && } + {page == "notifications" && ( + + )} {confirmationDialogOpen && ( >; +}; +export default function NotificationView({ + setUnsavedChanges, +}: NotificationsSettingsViewProps) { + const { data: config, mutate: updateConfig } = useSWR( + "config", + { + revalidateOnFocus: false, + }, + ); + + // status bar + + const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; + + // notification key handling + + const { data: publicKey } = useSWR( + config?.notifications?.enabled ? "notifications/pubkey" : null, + { revalidateOnFocus: false }, + ); + + const subscribeToNotifications = useCallback( + (registration: ServiceWorkerRegistration) => { + if (registration) { + addMessage( + "notification_settings", + "Unsaved Notification Registrations", + undefined, + "registration", + ); + + registration.pushManager + .subscribe({ + userVisibleOnly: true, + applicationServerKey: publicKey, + }) + .then((pushSubscription) => { + axios + .post("notifications/register", { + sub: pushSubscription, + }) + .catch(() => { + toast.error("Failed to save notification registration.", { + position: "top-center", + }); + pushSubscription.unsubscribe(); + registration.unregister(); + setRegistration(null); + }); + toast.success( + "Successfully registered for notifications. Restart to start receiving notifications.", + { + position: "top-center", + }, + ); + }); + } + }, + [publicKey, addMessage], + ); + + // notification state + + const [registration, setRegistration] = + useState(); + + useEffect(() => { + navigator.serviceWorker + .getRegistration(NOTIFICATION_SERVICE_WORKER) + .then((worker) => { + if (worker) { + setRegistration(worker); + } else { + setRegistration(null); + } + }) + .catch(() => { + setRegistration(null); + }); + }, []); + + // form + + const [isLoading, setIsLoading] = useState(false); + const formSchema = z.object({ + enabled: z.boolean(), + email: z.string(), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + enabled: config?.notifications.enabled, + email: config?.notifications.email, + }, + }); + + const onCancel = useCallback(() => { + if (!config) { + return; + } + + setUnsavedChanges(false); + form.reset({ + enabled: config.notifications.enabled, + email: config.notifications.email || "", + }); + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config, removeMessage, setUnsavedChanges]); + + const saveToConfig = useCallback( + async ( + { enabled, email }: NotificationSettingsValueType, // values submitted via the form + ) => { + axios + .put( + `config/set?notifications.enabled=${enabled}¬ifications.email=${email}`, + { + requires_restart: 0, + }, + ) + .then((res) => { + if (res.status === 200) { + toast.success("Notification settings have been saved.", { + position: "top-center", + }); + updateConfig(); + } else { + toast.error(`Failed to save config changes: ${res.statusText}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + toast.error( + `Failed to save config changes: ${error.response.data.message}`, + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, + [updateConfig, setIsLoading], + ); + + function onSubmit(values: z.infer) { + setIsLoading(true); + saveToConfig(values as NotificationSettingsValueType); + } + + return ( + <> +
+ +
+ + Notification Settings + + +
+
+

+ Frigate can natively send push notifications to your device when + it is running in the browser or installed as a PWA. +

+
+ + Read the Documentation{" "} + + +
+
+
+ +
+ + ( + + +
+ + { + return field.onChange(checked); + }} + /> +
+
+
+ )} + /> + ( + + Email + + + + + Entering a valid email is required, as this is used by the + push server in case problems occur. + + + + )} + /> +
+ + +
+ + + +
+
+ + +
+
+
+
+ + ); +}