📱 Mobile apps with Rask — PWA, offline & push
Build installable, offline, native-feeling mobile apps in C# — no Swift, Kotlin, React Native, or MAUI. A Rask WASM app is a Progressive Web App: it installs to the home screen, launches full-screen, works offline, sends push notifications, and reaches device capabilities (vibration, share sheet, geolocation, clipboard) through typed C# — the same component code you already write.
WASM vs Server. A WASM app gets the full PWA: install, true offline, push, and every device API. A Server app (opt in with
AddRaskPwa) is installable + push-capable — manifest, Web Push subscribe, local notifications, app badge, and wake lock all work — but it is not an offline app: it renders over a live WebSocket, so offline navigations show a static offline page, a custom install button uses the declarativeTrigger.Install(the imperativePrompt()stays WASM-only), and there is no background sync (WASM-only). See choosing a host template and PWA on the Server host below.
- Make your app a PWA
- Installable — the web app manifest
- Custom install button (
Window.OnBeforeInstallPrompt) - Offline — the service worker
- Background sync (
IBackgroundSync) - Push notifications (
PushManager) - PWA on the Server host
- Device capabilities for mobile
- Deploying (GitHub Pages & sub-paths)
Make your app a PWA
Every template is a PWA from the start (--no-pwa leaves it out):
rask new MyApp --template wasm # standalone browser-WASM PWA (full offline)
rask new MyApp # installable + push-capable Server app (not offline)
The WASM templates scaffold a manifest + icon and register Rask's default service worker from
index.html. On the Server template RaskApp serves the manifest + service worker and registers it
for you, plus a static offline.html (AddRaskPwa(...) on a hand-wired host). To add PWA to an existing app, follow the steps
below — the manifest and, for WASM,
service-worker registration; for Server, just
AddRaskPwa.
Installable — the web app manifest
Configure a typed WebAppManifest (in Rask.Core.Browser) in Program.cs — on WASM the framework
injects the <link rel="manifest"> (a data: URL, so no manifest.webmanifest file to ship) and
the <meta name="theme-color"> at boot; on Server AddRaskPwa serves and links it. There's nothing to
hand-write or keep in sync. A theme-color your page's head declares (including a light/dark media
pair) wins: the manifest's ThemeColor is added only when the page names none, so the browser's toolbar
tint never switches colour as the app boots.
var host = WasmHostBuilder.CreateDefault();
host.UsePwa(new WebAppManifest
{
Name = "My Rask App",
ShortName = "Rask App",
ThemeColor = "#512BD4",
BackgroundColor = "#faf9fe",
Display = DisplayMode.Standalone,
Icons = [new ManifestIcon("icon.svg", "any", "image/svg+xml", "any maskable")]
});
await host.Run<App>();
Relative URLs (StartUrl/Scope default to ".", and icon src) are made absolute against
<base href> when applied, so they stay correct under a sub-path deploy (GitHub Pages). Put your
icon(s) in wwwroot (the templates ship an icon.svg). WebAppManifest.ToJson() is also
available if you'd rather serve a physical manifest.webmanifest (e.g. from an ASP.NET host).
Beyond the basics, WebAppManifest also exposes typed members for the richer manifest features — all
optional and omitted when unset:
| Member | Manifest key | Use |
|---|---|---|
Categories |
categories |
Store/launcher category hints |
Orientation |
orientation |
Preferred orientation (ManifestOrientation) |
DisplayOverride |
display_override |
Ordered fallback modes (e.g. WindowControlsOverlay) |
Shortcuts |
shortcuts |
Home-screen / jump-list entries (ManifestShortcut) |
Screenshots |
screenshots |
Richer install-UI previews (ManifestScreenshot) |
ShareTarget |
share_target |
Receive content from the OS share sheet (ShareTarget) |
FileHandlers |
file_handlers |
Open associated file types (FileHandler) |
host.UsePwa(new WebAppManifest
{
Name = "My Rask App",
Icons = [new ManifestIcon("icon.svg", "any", "image/svg+xml", "any maskable")],
Categories = ["productivity"],
Shortcuts = [new ManifestShortcut("New note", "/new", ShortName: "New")],
});
Custom install button (Window.OnBeforeInstallPrompt)
By default the browser shows its own small "install" hint. To present your own install button
instead, listen for MDN's beforeinstallprompt with Window.OnBeforeInstallPrompt from
Rask.Web. The browser fires it once, at load, before any component
can subscribe, so Rask keeps it from boot and hands it to each later subscriber until it is spent:
using Rask.Web;
public sealed partial class InstallButton : Component
{
private Rask.Web.Types.BeforeInstallPromptEvent? _prompt;
private IAsyncDisposable? _offer;
protected override async Task OnFirstRender()
{
if (await Window.MatchMedia("(display-mode: standalone)").Matches) return; // already installed
_offer = await Window.OnBeforeInstallPrompt(e => _prompt = e);
}
protected override Component? Render() => _prompt is not null
? Button.OnClick(Prompt)["Install app"]
: Text("");
private async Task Prompt()
{
var answer = await _prompt!.Prompt();
var accepted = answer.UserChoice == AppBannerPromptOutcome.Accepted;
_prompt = null; // the prompt is one-shot
}
protected override async Task OnUnmount()
{
if (_offer is not null) await _offer.DisposeAsync();
}
}
Prompt() needs the click, so it runs in a WASM app. On the Server host, the declarative
Trigger.Install shows the same kept prompt inside the click. Window.OnAppInstalled(…) tells you when the
user installed the app.
The browser only fires beforeinstallprompt when its install criteria are met (valid manifest,
service worker, HTTPS) and once per page load, so show your button only once the event has arrived, and hide
it when (display-mode: standalone) matches. iOS Safari has no beforeinstallprompt (users install via the
Share sheet), so the handler never runs there — keep your manual "Add to Home Screen" hint as a fallback.
Offline — the service worker
Rask ships a default service worker, rask-sw.js, served at the app root. It does two jobs:
- Offline app shell — a network-first runtime cache (fresh when online, served from cache when
offline), with navigations falling back to the cached shell so deep links work offline. Files
named by their content hash are the exception: a fingerprinted file under
_framework/(dotnet.native.nxw7lo0lh5.wasm) and a scoped-asset bundle (/_rask/a/{hash}.css|js) are served cache-first, so a repeat visit does not download the .NET runtime again. Their bytes can never change under that name;index.html,main.jsand anything unfingerprinted stay network-first. - Web Push — shows the pushed notification and focuses/opens a window on click.
Register it from index.html (the templates do this for you). It resolves relative to
<base href>, so it works at the origin root and under a sub-path deploy:
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
var base = document.querySelector("base");
var scope = base ? new URL(base.href).pathname : "/";
navigator.serviceWorker.register(scope + "rask-sw.js").catch(function () {});
});
}
</script>
What it keeps is the shell, not your data. The Cache Storage API honours no header by itself, so the worker decides, per response:
| Response | Kept offline |
|---|---|
Marked Cache-Control: no-store or private |
Never |
A navigation; a script, style, font, image or manifest; anything under _framework/, _rask/, _content/ |
Yes |
Anything else — a fetch("/api/orders") |
Only when the server marks it Cache-Control: public |
So an authenticated API answer is not replayed offline to whoever opens the browser next, and data the
app must read offline belongs in a local store (browser SQLite, IndexedDB) rather
than in the HTTP cache. Signing out — Auth.SignOut(), or IAuthSignIn.SignOut — empties the cache
as well. It fills again as the browser next loads the app while online; sign-out itself navigates
without a reload, so an app signed out of and then opened offline has no shell until it has been
loaded online once more. Two limits to know: the cache is emptied by the framework's own sign-out
only — not by a cookie that expires or a logout the app wrote itself — and a navigation or an image
is kept unless its response says no-store or private, so mark one that shows a person's own data.
Bring your own worker (custom caching/routing) by registering a different URL there instead. Whichever
worker the page registered is the one await Navigator.ServiceWorker.Ready answers, so push follows it.
Background sync (IBackgroundSync)
Ask the browser to wake the app when connectivity returns — so an edit made on a train is flushed
without the user coming back to the tab and waiting for a spinner. IBackgroundSync (in
Rask.Wasm.Browser, injected through the constructor) wraps both the
Background Synchronization API
and Periodic Background Sync.
It rides the service worker above, so it is WASM-only and needs no extra wiring in a scaffolded app.
Know the boundary before you design around it
The browser fires the sync even with the tab closed. Rask's guarantee is narrower, and that gap is the thing to design around: the .NET runtime lives in the page, not in the service worker. Your C# runs only while a client is open. Rask's service worker forwards the woken-up tag to every open client; with none open, the registration is consumed without your handler seeing it.
So:
- Re-request your tags at boot. A registration is best-effort, not durable queue state. Keep the
work itself in
IIndexedDbor OPFS and let the sync be the nudge to drain it, never the store. - The realistic win is a backgrounded tab, not a closed one. A hidden or frozen tab is still a client, so it wakes and drains the moment the network is back — which is the case most offline-first apps actually hit.
Support is Chromium-only at the time of writing. Every call degrades to "unavailable" rather than throwing, so a feature check is optional and a fallback is not.
Draining an offline queue
public sealed class DraftQueue(IBackgroundSync sync) : Component, IAsyncDisposable
{
private IAsyncDisposable? _subscription;
public override async Task OnFirstRender()
{
// Subscribe BEFORE requesting. A sync that landed while the page was still booting is held for
// the first subscriber, so an event that beat your startup code still reaches it.
_subscription = await sync.OnSync(async e =>
{
if (e.Tag == "flush-drafts") await Flush();
StateHasChanged();
});
await sync.RequestSync("flush-drafts"); // best-effort, and re-requested every boot
}
public async ValueTask DisposeAsync() =>
await (_subscription?.DisposeAsync() ?? ValueTask.CompletedTask);
}
OnSync is a subscription handler, not a chain-set callback, so calling StateHasChanged() in it
is correct and RASK026 does not apply — the same rule as every other pushed API here.
Periodic sync
Periodic sync is gated on a permission the browser grants on its terms (Chromium ties it to the app being installed and to site engagement). There is no API to request it, so check, don't ask:
if (await sync.IsPeriodicSupported() && await sync.GetPeriodicPermission() == "granted")
{
// A floor, not a schedule: the browser decides the real cadence from engagement and battery, and
// in practice fires far less often than you ask.
await sync.RequestPeriodicSync("refresh-feed", 12.Hours);
}
OnSync delivers both kinds — check BackgroundSyncEvent.Periodic to tell them apart.
GetPendingTags() / GetPeriodicTags() list what is registered, and
UnregisterPeriodic(tag) removes a recurring one.
Full reference: IBackgroundSync.
Push notifications (PushManager)
A browser subscribes to Web Push through MDN's own PushManager,
from Rask.Web — there is no Rask wrapper. It hangs off the service worker the page already
registered (rask-sw.js: the templates' index.html on WASM, AddRaskPwa's
<head> on Server), so the subscription starts at Navigator.ServiceWorker.Ready. Drive it from an event handler:
using System.Buffers.Text;
public sealed partial class PushButton : Component
{
private async Task Enable()
{
// Settles once rask-sw.js is active; the registration is a kept browser object, so dispose of it.
await using var worker = await Navigator.ServiceWorker.Ready;
await using var subscription = await worker.PushManager.Subscribe(new()
{
UserVisibleOnly = true, // every push shows a notification
ApplicationServerKey = Base64Url.DecodeFromChars(Push.PublicKey), // the app's VAPID public key
});
await Push.Subscribe(await subscription.ToJSON()); // kept on the app's database
}
}
Subscribe asks for notification permission itself; on WASM, await Notification.RequestPermission() in the click
asks first. Subscribing again with the same key answers the subscription the browser already has, and
await subscription.Unsubscribe() removes it. A WebAssembly client has no Push battery in its process: it reads
the key from GET /_rask/push/key and posts await subscription.ToJSON() — MDN's PushSubscriptionJSON, as is — to
POST /_rask/push/subscribe.
The default rask-sw.js receives a push and shows a notification from its JSON payload
({ title, body, icon, tag, data: { url } }), so nothing else runs in the browser.
Sending from your backend (Rask.WebPush)
Full reference: Rask.WebPush. The essentials:
Included in
Rask.Server— nothing to install. It is on; an app that does without it says so:app.Configure(c => c.Push.Off());
The subscriptions live in a table on the app's own database, so a send is one line:
await Push.Send(WebPushMessage.Text("New message", "You have one unread item.", "/inbox"));
await Push.Send(WebPushMessage.Text("Your order shipped")).To(userId); // one person's devices
A subscription the push service says is gone is dropped as the send finds it. A WebAssembly client posts its
subscription.ToJSON() to POST /_rask/push/subscribe instead of calling Push.Subscribe, and reads the public key
from GET /_rask/push/key.
The keys come from Rask:Push: rask new wrote a development pair to the gitignored
appsettings.Development.json, and deployed they come from the environment
(Rask__Push__VapidKeys__PublicKey / __PrivateKey). The contact is Rask:Push:Subject, a mailto: or
https: address. VapidKeys.Generate() mints a pair; never regenerate a live one.
PWA on the Server host
A Rask Server app can be a PWA too — opt in with AddRaskPwa, the server-side counterpart to
the WASM host's UsePwa. One call makes the app installable and push-capable:
using Rask.Core.Browser;
using Rask.Server;
builder.Services.AddRask();
builder.Services.AddRaskPwa(new WebAppManifest
{
Name = "My Rask App",
ShortName = "Rask App",
ThemeColor = "#512BD4",
Display = DisplayMode.Standalone,
Icons = [new ManifestIcon("icon.svg", "any", "image/svg+xml", "any maskable")]
});
AddRaskPwa:
- serves the manifest at
{PathBase}/rask/manifest.webmanifest(relative URLs rooted at the app's base path) and emits the<link rel="manifest">+<meta name="theme-color">directly into the server-rendered<head>— no boot-time JS injection; - serves Rask's service worker at
{PathBase}/rask-sw.jsand auto-registers it, so the app meets install criteria with no extra wiring; - works with MDN's
Navigator.WakeLock,PushManager,NotificationandNavigator.SetAppBadgefromRask.Web.
Then ship a static wwwroot/offline.html (the SW serves it on failed navigations) and, to send
push, add Rask.WebPush.
What you don't get on Server. A Server app renders over a live WebSocket, so it is not an offline app: the service worker deliberately does not cache the server-rendered shell (it carries a one-shot session id and is served
no-store), so offline navigations showoffline.htmlrather than a dead cached page. The activation-bound imperative calls (the install event'sPrompt(),GetDisplayMedia(),RequestFullscreen(), …) are not available on Server (Trigger.Installand the other gesture triggers run them in the click instead), and neither is background sync — it rides the service-worker registration and needs a client-side runtime to wake into, which a WebSocket-rendered app does not have. The honest framing: installable + push + native-feel, not an offline app. (Sharing still works on Server via the headlessShareableinRask.Core, which firesnavigator.sharein the click gesture; the imperativeNavigator.Share(…)fromRask.Webcompiles only in a WASM app.)
Device capabilities for mobile
The browser APIs that make a web app feel native. Rows marked (Rask.Web) are MDN's own surface from
Rask.Web (imported for you); the rest are typed wrappers. Everything in Rask.Core.Browser
works on both transports (and is registered on Server too), as do the screen wake lock from Rask.Web and the
headless declarative Shareable (all hosts). The
*(WASM)* ones need a live user gesture or the installed-app instance the Server round-trip can't carry: the
device/handle set is Rask.Web members that run on WebAssembly alone, background sync is IBackgroundSync in
Rask.Wasm.Browser, and none is registered on Server.
| Capability | API | Use |
|---|---|---|
| Share sheet | Shareable (all) / Navigator.Share(…) (Rask.Web, WASM) |
Headless declarative share works everywhere; Navigator.Share for code-driven shares |
| Vibration | Navigator.Vibrate(200) (Rask.Web) |
Haptic feedback |
| Geolocation | Navigator.Geolocation (Rask.Web) |
Current position (GetCurrentPosition) + live tracking (WatchPosition) |
| Clipboard | Navigator.Clipboard (Rask.Web) |
Copy/paste (WriteText / ReadText) |
| Storage / Cookies | LocalStorage / Document.Cookie (Rask.Web) |
Persist state on-device |
| Large storage | IIndexedDb |
Async key/value store backed by IndexedDB — cache app data offline |
| Files on disk | Window.ShowOpenFilePicker(…) (Rask.Web, WASM) |
Open a file, then ShowSaveFilePicker / CreateWritable() to save it back (editors, file managers) |
| Passkeys | IWebAuthn |
Passwordless register / sign-in with a biometric or security key |
| Permissions | Navigator.Permissions.Query(…) (Rask.Web) |
Check before prompting |
| Page visibility | Document.VisibilityState (Rask.Web) |
Pause work when backgrounded |
| Online status | Navigator.OnLine (Rask.Web) |
An offline indicator |
| Network quality | Navigator.Connection (Rask.Web) |
EffectiveType / Downlink / SaveData, to adapt loading |
| Media queries | Window.MatchMedia(query) (Rask.Web) |
.Matches, and OnChange to follow it |
| Speech (text-to-speech) | SpeechSynthesis.Speak(utterance) (Rask.Web) |
Speak a SpeechSynthesisUtterance; Cancel() stops it |
| Screen info | Screen (Rask.Web) |
Width / Height / ColorDepth; Window.DevicePixelRatio for retina |
| Storage estimate | Navigator.Storage.Estimate() (Rask.Web) |
Quota / Usage, to budget offline caches; Persist() to survive eviction |
| Visual viewport | Window.VisualViewport (Rask.Web) |
Visible size/offset/zoom, e.g. above the soft keyboard |
| Cross-tab messaging | BroadcastChannel.Create(name) (Rask.Web) |
PostMessage / OnMessage — sync sign-out, theme, "data updated" across tabs |
| Local notifications | Notification.Create(title, …) (Rask.Web) |
Show a notification from the page (no server); Notification.RequestPermission() (WASM) first |
| App badge | Navigator.SetAppBadge(3) (Rask.Web) |
Unread count on the installed icon (SetAppBadge(3) / ClearAppBadge()) |
| Wake lock | Navigator.WakeLock.Request(WakeLockType.Screen) (Rask.Web) |
Keep the screen awake; Release() the sentinel to let it sleep |
| Device tilt / motion | Window.OnDeviceOrientation / OnDeviceMotion (Rask.Web) |
Gyroscope and accelerometer events; every: throttles them in the browser |
| Screen orientation | Screen.Orientation (Rask.Web) |
Read orientation; Lock(…) (WASM) / Unlock() (needs fullscreen) |
| Fullscreen | _stage.RequestFullscreen() (Rask.Web, WASM) |
Present an element fullscreen; Document.ExitFullscreen() leaves |
| Camera / mic / screen | Navigator.MediaDevices (Rask.Web) |
GetUserMedia(…), or GetDisplayMedia() (WASM); show it with _video.SetSrcObject(stream) |
| Picture-in-Picture | _video.RequestPictureInPicture() (Rask.Web, WASM) |
Float a <video> into an always-on-top miniplayer |
| Gamepad | Navigator.GetGamepads() (Rask.Web) |
Read connected controllers — Buttons / Axes; Window.OnGamepadConnected |
| Idle detection | IdleDetector.Create() (Rask.Web, WASM) |
Auto-lock / presence when the user goes idle or the screen locks |
| EyeDropper | EyeDropper.Create() (Rask.Web, WASM) |
Pick a color from anywhere on screen (Open()) |
| Serial device | Navigator.Serial (Rask.Web, WASM) |
Talk to an Arduino / serial device — RequestPort(…), then Open(…), Readable.GetReader() / Writable.GetWriter() |
| USB device | Navigator.Usb (Rask.Web, WASM) |
Pair with and drive a USB device — RequestDevice(…), then Open() / TransferIn / TransferOut |
| HID device | Navigator.Hid (Rask.Web, WASM) |
Talk to a HID device — RequestDevice(…), then SendReport / OnInputReport |
| Bluetooth (BLE) | Navigator.Bluetooth (Rask.Web, WASM) |
Pair with a BLE device — RequestDevice(…), then read a characteristic's ReadValue() as byte[] |
| Background sync | IBackgroundSync (WASM) |
Wake the app to drain an offline queue when connectivity returns, or on a schedule |
App badge. await Navigator.SetAppBadge(3) from Rask.Web sets a count on the installed
app's icon. SetAppBadge() shows a plain dot and ClearAppBadge() removes it. It does nothing in a normal browser
tab. Pair it with notifications or push to show an unread count.
Wake lock. _sentinel = await Navigator.WakeLock.Request(WakeLockType.Screen) from
Rask.Web keeps the screen on, on both hosts, with no click needed. Keep
the sentinel while the screen should stay on, then await _sentinel.Release() and await _sentinel.DisposeAsync().
Disposing alone does not release the lock, just as dropping a sentinel in JS doesn't. Every browser drops the
lock when the page is hidden; Rask takes it again each time the page becomes visible, so the sentinel holds until
you release it. Released turns true and OnRelease fires once — when you release it, or when the browser refuses
it back.
Screen orientation. await Screen.Orientation.Type and await Screen.Orientation.Angle read it.
await Screen.Orientation.Lock(…) (WASM, in a click) and Unlock() lock it — locking usually requires fullscreen
and is often unsupported on desktop, so wrap it in try/catch. On the Server host, use Trigger.ScreenOrientation.
Fullscreen. await _stage.RequestFullscreen() from Rask.Web presents an
element ref fullscreen (WASM, in the click). await Document.ExitFullscreen() leaves. await Document.FullscreenEnabled
says whether it can, and await Document.FullscreenElement == _stage whether it is yours. On the Server host, use
Trigger.Fullscreen. Request fullscreen first when you also want to lock the orientation — most browsers only
allow the lock in fullscreen.
Camera and microphone. await Navigator.MediaDevices.GetUserMedia(new() { Video = new() { Width = 640, FacingMode = "user" } })
from Rask.Web asks for the camera and hands back a kept MediaStream.
await _video.SetSrcObject(stream) shows it. Stop it with await stream.GetTracks() and Stop() on each track.
Screen capture is GetDisplayMedia() (WASM, in the click). On the Server host, Trigger.MediaCapture runs the
camera in the click too: .OnStream(stream => _camera = MediaStream.From(stream)) keeps the stream, and
foreach (var t in await _camera.GetTracks()) await t.Stop(); stops it.
Device tilt and motion. await Window.OnDeviceOrientation(e => _angle = e.Alpha, every: 100.Milliseconds) and
Window.OnDeviceMotion(e => …, every: …) from Rask.Web follow the gyroscope
and accelerometer. every: throttles the events in the browser before they cross. Dispose of the subscription to
stop. iOS asks first: await DeviceOrientationEvent.RequestPermission() (WASM, in the click).
Local vs push notifications. Notification from Rask.Web shows a notification directly from
the running page. Ask first with await Notification.RequestPermission() (WASM, in the click), then
await using var n = await Notification.Create("Title", new() { Body = "…" }). await Notification.Permission reads
the answer. Use it for in-app alerts. For notifications delivered while the app is closed, use
PushManager — those go through the service worker.
See JS interop → Typed browser APIs for the full surface.
Deploying (GitHub Pages & sub-paths)
Publish with RaskPathBase so the <base href>, manifest, and service-worker scope resolve under
the sub-path:
dotnet publish -c Release /p:RaskPathBase=/my-repo
The manifest's relative start_url/scope and the <base href>-relative SW registration handle the
prefix automatically — the published app is installable and offline at https://you.github.io/my-repo/.
The Rask showcase itself is a deployed WASM PWA — install it from
the live demo.