PWA — notifications & push
A live demo of the WASM-only PWA APIs. This site is itself an installable, offline PWA — install it from your browser's address bar, then try the buttons below.
Local notifications, Web Push readiness, and the installed-app badge — all WASM-only (they need a live user gesture or the installed-PWA instance the Server round-trip can't carry).
using System.Text;
using System.Text.Json;
using Rask.Core.Browser;
using Rask.Site;
namespace Rask.Site.Features;
/// <summary>
/// A live, WASM-only PWA demo: local notifications (<see cref="INotifications" />), Web Push
/// readiness (<see cref="IWebPush" />), and the installed-app badge (<see cref="IBadge" />). These
/// APIs are WASM-only; <see cref="PwaPage" /> hosts this demo (with its source) in the showcase.
/// </summary>
public sealed partial class PwaDemo(INotifications notifications, IWebPush push, IBadge badge, HttpClient http) : Component
{
// Fallback VAPID public key for the standalone static showcase (no backend to ask). When a backend
// is present the key comes from GET /_push/key instead, so the two never drift.
private const string DemoVapidPublicKey =
"BIl5ANiAgh51-r7wwTyN047Hn3FWTCgLl9cGff1qa5vrft1DmS3jSa-JhTf3PfC6qa_G33YNeNVKT-yyP_6Jqik";
private string? _notifyStatus;
private string? _pushStatus;
private bool _subscribed;
private string? _badgeStatus;
private int _badgeCount;
protected override Component? Render() =>
[
Div.Class($"{Tw.Card} shadow-sm border-0 mb-3")[
Div.Class(Tw.CardBody)[
H6.Class("font-bold")[UiIcon.Name(UiIconName.Bell).Class("me-2"), "Local notification (INotifications)"],
P.Class("text-sm text-ui-muted")[
"Requests permission, then shows a notification straight from C# — no server."
],
Button.Class($"{Tw.BtnPrimary} mb-2").Id("pwa-notify").OnClickAsync(ShowNotification)[
"Show a notification"],
Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("pwa-notify-status")[_notifyStatus ?? "(idle)"]]
]
],
Div.Class($"{Tw.Card} shadow-sm border-0 mb-3")[
Div.Class(Tw.CardBody)[
H6.Class("font-bold")[UiIcon.Name(UiIconName.Signal).Class("me-2"), "Web Push (IWebPush)"],
P.Class("text-sm text-ui-muted")[
"Subscribes with a demo VAPID key and registers with this app's ", Code["Rask.WebPush"],
" backend, then sends a real push that the service worker shows even when the tab is ",
"closed. Run the hosted sample (", Code["Rask.Site.Host"],
") for the full loop — see ", Code["docs/pwa.md"], "."
],
Div.Class("flex gap-2 flex-wrap mb-2")[
Button.Class(Tw.BtnOutlinePrimary).Id("pwa-push").OnClickAsync(EnablePush)[
"Enable push (subscribe)"],
Button
.Class(Tw.BtnPrimary)
.Id("pwa-push-send")
.Disabled(!_subscribed)
.OnClickAsync(SendTestPush)[
"Send a test push"]
],
Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("pwa-push-status")[_pushStatus ?? "(idle)"]]
]
],
Div.Class($"{Tw.Card} shadow-sm border-0")[
Div.Class(Tw.CardBody)[
H6.Class("font-bold")[UiIcon.Name(UiIconName.Overview).Class("me-2"), "App badge (IBadge)"],
P.Class("text-sm text-ui-muted")[
"Sets a count on the installed app's icon — install the PWA first, then watch the icon. ",
"A silent no-op in a normal browser tab."
],
Div.Class("flex gap-2 flex-wrap mb-2")[
Button.Class(Tw.BtnOutlinePrimary).Id("pwa-badge-inc").OnClickAsync(BumpBadge)[
"Increment badge"],
Button.Class(Tw.BtnOutlineDanger).Id("pwa-badge-clear").OnClickAsync(ClearBadge)[
"Clear badge"]
],
Div.Class("text-sm text-ui-muted")["Status: ", Code.Id("pwa-badge-status")[_badgeStatus ?? "(idle)"]]
]
]
];
private async Task ShowNotification()
{
try
{
if (!await notifications.IsSupportedAsync())
{
_notifyStatus = "Notifications not supported in this browser";
return;
}
var permission = await notifications.RequestPermissionAsync();
if (permission != NotificationPermission.Granted)
{
_notifyStatus = $"Permission: {permission}";
return;
}
await notifications.ShowAsync("Hello from Rask", new NotificationOptions
{
Body = "A local notification, shown from C#.",
Tag = "rask-pwa-demo"
});
_notifyStatus = "Notification shown";
}
catch (Exception ex)
{
_notifyStatus = "Failed: " + ex.Message;
}
}
private async Task EnablePush()
{
try
{
if (!await push.IsSupportedAsync())
{
_pushStatus = "Push not supported in this browser";
return;
}
var permission = await push.RequestPermissionAsync();
if (permission != NotificationPermission.Granted)
{
_pushStatus = $"Permission: {permission}";
return;
}
await push.RegisterServiceWorkerAsync();
// Use the backend's VAPID public key when there is one, so the client and server can't
// drift; fall back to the baked-in demo key on the static showcase.
var vapidKey = await TryGetServerVapidKey() ?? DemoVapidPublicKey;
var sub = await push.GetSubscriptionAsync() ?? await push.SubscribeAsync(vapidKey);
_subscribed = true;
// Register the subscription with this app's backend. The flat { endpoint, p256dh, auth }
// shape is what the /_push/subscribe endpoint expects; the secrets are never rendered to
// the page. On the standalone static showcase there is no backend, so a failure is expected.
try
{
var json = $"{{\"endpoint\":\"{sub.Endpoint}\",\"p256dh\":\"{sub.P256dh}\",\"auth\":\"{sub.Auth}\"}}";
using var content = new StringContent(json, Encoding.UTF8, "application/json");
var response = await http.PostAsync("_push/subscribe", content);
_pushStatus = response.IsSuccessStatusCode
? "Subscribed and registered with the backend — click \"Send a test push\"."
: $"Subscribed, but the backend returned {(int)response.StatusCode}.";
}
catch (HttpRequestException)
{
_pushStatus = "Subscribed. No backend here (static showcase) — run Rask.Site.Host for the full loop.";
}
}
catch (Exception ex)
{
_pushStatus = "Failed: " + ex.Message;
}
}
private async Task SendTestPush()
{
try
{
// Ask the backend to deliver a push to every stored subscription. The browser's service
// worker shows the notification — even if this tab is closed. The deep-link URL uses the
// type-safe generated route so a renamed page is a compile error, not a dead link.
var body = $"{{\"title\":\"Rask push\",\"body\":\"Delivered by Rask.WebPush.\",\"url\":\"{Routes.PwaPage()}\"}}";
using var content = new StringContent(body, Encoding.UTF8, "application/json");
var response = await http.PostAsync("_push/send", content);
_pushStatus = response.IsSuccessStatusCode
? "Push sent — watch for the notification."
: $"Backend returned {(int)response.StatusCode}.";
}
catch (HttpRequestException)
{
_pushStatus = "No backend here (static showcase) — run Rask.Site.Host for the full loop.";
}
catch (Exception ex)
{
_pushStatus = "Failed: " + ex.Message;
}
}
// The backend's current VAPID public key, or null when there is no backend (static showcase).
private async Task<string?> TryGetServerVapidKey()
{
try
{
using var doc = JsonDocument.Parse(await http.GetStringAsync("_push/key"));
return doc.RootElement.TryGetProperty("publicKey", out var key) ? key.GetString() : null;
}
catch (HttpRequestException)
{
return null;
}
}
private async Task BumpBadge()
{
try
{
if (!await badge.IsSupportedAsync())
{
_badgeStatus = "App badges not supported in this browser";
return;
}
await badge.SetAsync(++_badgeCount);
_badgeStatus = $"Badge set to {_badgeCount} (visible on the installed icon)";
}
catch (Exception ex)
{
_badgeStatus = "Failed: " + ex.Message;
}
}
private async Task ClearBadge()
{
try
{
_badgeCount = 0;
await badge.ClearAsync();
_badgeStatus = "Badge cleared";
}
catch (Exception ex)
{
_badgeStatus = "Failed: " + ex.Message;
}
}
}
Local notification (INotifications)
Requests permission, then shows a notification straight from C# — no server.
(idle)Web Push (IWebPush)
Subscribes with a demo VAPID key and registers with this app's Rask.WebPush backend, then sends a real push that the service worker shows even when the tab is closed. Run the hosted sample (Rask.Site.Host) for the full loop — see docs/pwa.md.
(idle)App badge (IBadge)
Sets a count on the installed app's icon — install the PWA first, then watch the icon. A silent no-op in a normal browser tab.
(idle)