Rask
Rask
v0.23.1-alpha.0.496

PWA & device APIs

The browser's device and app capabilities called from C#, live — MDN's own APIs, through Rask.Web. Nearly all are WASM-only — each of those needs a live user gesture, the live document, or a device handle that a Server round-trip cannot carry; the screen wake lock and the declarative install trigger are the exceptions — so each demo runs in your browser, in this page's own WebAssembly app. This site is itself an installable, offline PWA: install it from your address bar and the same code runs as an app.

Every one follows the same shape. Ask whether the capability exists before you offer it (IsSupported), call it from a real click, and dispose what it hands back — most of these return an IAsyncDisposable that releases the hardware or the lock. A request without user activation rejects, and so does a chooser the reader dismisses — catch JSException and treat dismissal as an answer, not an error.

Notifications, push & badge

Local notifications, Web Push readiness and the installed-app badge — the three that make an installed app feel like one.

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).

PwaDemo.cs
using System.Buffers.Text;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.JSInterop;

namespace Rask.Site.Features;

/// <summary>
///     A live, WASM-only PWA demo: local notifications (MDN's <c>Notification</c>), a Web Push subscription
///     (MDN's <c>PushManager</c>), and the installed-app badge (MDN's <c>navigator.setAppBadge</c>), all from
///     Rask.Web. <see cref="PwaPage" /> hosts this demo (with its source) in the showcase.
/// </summary>
public sealed partial class PwaDemo(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 /_rask/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() =>
    [
        Ui.Card.Class("mb-3")[
                H6.Class("font-bold")[Ui.Icon.Name(Ui.IconName.BellAlert).Class("size-5 me-2"), "Local notification (Notification)"],
                P.Class("text-sm text-zinc-500 dark:text-white/70")[
                    "Requests permission, then shows a notification straight from C# — no server."
                ],
                Ui.Button.Primary.Class("mb-2").Id("pwa-notify").OnClick(ShowNotification)["Show a notification"],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("pwa-notify-status")[_notifyStatus ?? "(idle)"]]
            ],

        Ui.Card.Class("mb-3")[
                H6.Class("font-bold")[Ui.Icon.Name(Ui.IconName.Signal).Class("size-5 me-2"), "Web Push (PushManager)"],
                P.Class("text-sm text-zinc-500 dark:text-white/70")[
                    "Subscribes, then hands the subscription to a ", Code["RaskApp"],
                    " host's Web Push battery at ", Code["/_rask/push/subscribe"],
                    ". The host sends with ", Code["Push.Send(…)"],
                    ", and the service worker shows it even when the tab is closed. This showcase has no host, ",
                    "so it stops at the subscription — see ", Code["docs/webpush.md"], "."
                ],
                Div.Class("flex gap-2 flex-wrap mb-2")[
                    Ui.Button.Id("pwa-push").OnClick(EnablePush)["Enable push (subscribe)"],
                    Ui.Button.Primary
                        .Id("pwa-push-send")
                        .Disabled(!_subscribed)
                        .OnClick(SendTestPush)["Send a test push"]
                ],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("pwa-push-status")[_pushStatus ?? "(idle)"]]
            ],

        Ui.Card[
                H6.Class("font-bold")[Ui.Icon.Name(Ui.IconName.Squares2x2).Class("size-5 me-2"), "App badge (Navigator.SetAppBadge)"],
                P.Class("text-sm text-zinc-500 dark:text-white/70")[
                    "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")[
                    Ui.Button.Id("pwa-badge-inc").OnClick(BumpBadge)["Increment badge"],
                    Ui.Button.Red.Id("pwa-badge-clear").OnClick(ClearBadge)["Clear badge"]
                ],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("pwa-badge-status")[_badgeStatus ?? "(idle)"]]
            ]
    ];

    private async Task ShowNotification()
    {
        try
        {
            if (!await Notification.IsSupported)
            {
                _notifyStatus = "Notifications not supported in this browser";
                return;
            }

            var permission = await Notification.RequestPermission();
            if (permission != Types.NotificationPermission.Granted)
            {
                _notifyStatus = $"Permission: {permission}";
                return;
            }

            await using var shown = await Notification.Create("Hello from Rask", new()
            {
                Body = "A local notification, shown from C#.",
                Tag = "rask-pwa-demo"
            });
            _notifyStatus = "Notification shown";
        }
        catch (JSException ex)
        {
            _notifyStatus = "Failed: " + ex.Message;
        }
    }

    private async Task EnablePush()
    {
        try
        {
            if (!await PushManager.IsSupported)
            {
                _pushStatus = "Push not supported in this browser";
                return;
            }

            var permission = await Notification.RequestPermission();
            if (permission != Types.NotificationPermission.Granted)
            {
                _pushStatus = $"Permission: {permission}";
                return;
            }

            // The page shell registered rask-sw.js, which shows each push; Ready settles once it is active.
            await using var worker = await Navigator.ServiceWorker.Ready;

            // 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. Subscribing again with
            // the same key answers the subscription the browser already has.
            var vapidKey = await TryGetServerVapidKey() ?? DemoVapidPublicKey;
            await using var subscription = await worker.PushManager.Subscribe(new()
            {
                UserVisibleOnly = true,
                ApplicationServerKey = Base64Url.DecodeFromChars(vapidKey)
            });
            _subscribed = true;

            // Hand the subscription to the host's Web Push battery in MDN's own toJSON() shape; the
            // secrets are never rendered to the page. On the standalone static showcase there is no
            // backend, so a failure is expected.
            try
            {
                using var response = await http.PostAsJsonAsync("_rask/push/subscribe", await subscription.ToJSON(),
                    PushJsonContext.Default.PushSubscriptionJSON);
                _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), so nothing will send to it.";
            }
        }
        catch (JSException ex)
        {
            _pushStatus = "Failed: " + ex.Message;
        }
    }

    private async Task SendTestPush()
    {
        try
        {
            // Sending is server code, so a host maps one endpoint of its own for this button: a POST to
            // /push/test whose handler awaits Push.Send with a WebPushMessage.Text. The browser's service worker shows the notification — even if this tab is closed.
            var response = await http.PostAsync("push/test", content: null);
            _pushStatus = response.IsSuccessStatusCode
                ? "Push sent — watch for the notification."
                : $"Backend returned {(int)response.StatusCode}.";
        }
        catch (HttpRequestException)
        {
            _pushStatus = "No backend here (static showcase) — a RaskApp host sends with Push.Send(…).";
        }
        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("_rask/push/key"));
            return doc.RootElement.TryGetProperty("publicKey", out var key) && key.GetString() is { Length: > 0 } value
                ? value
                : null;
        }
        catch (Exception ex) when (ex is HttpRequestException or JsonException)
        {
            // No backend, or a static host answering with its HTML fallback.
            return null;
        }
    }

    // setAppBadge is a method, so there is nothing to ask first: a browser without it rejects the call.
    private async Task BumpBadge()
    {
        try
        {
            await Navigator.SetAppBadge(++_badgeCount);
            _badgeStatus = $"Badge set to {_badgeCount} (visible on the installed icon)";
        }
        catch (JSException ex)
        {
            _badgeStatus = "App badges not supported in this browser: " + ex.Message;
        }
    }

    private async Task ClearBadge()
    {
        try
        {
            _badgeCount = 0;
            await Navigator.ClearAppBadge();
            _badgeStatus = "Badge cleared";
        }
        catch (JSException ex)
        {
            _badgeStatus = "Failed: " + ex.Message;
        }
    }
}
Live result
Local notification (Notification)

Requests permission, then shows a notification straight from C# — no server.

Status: (idle)
Web Push (PushManager)

Subscribes, then hands the subscription to a RaskApp host's Web Push battery at /_rask/push/subscribe. The host sends with Push.Send(…), and the service worker shows it even when the tab is closed. This showcase has no host, so it stops at the subscription — see docs/webpush.md.

Status: (idle)
App badge (Navigator.SetAppBadge)

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.

Status: (idle)

Install prompt

Show your own "Install app" button with MDN's beforeinstallprompt from Rask.Web. The browser fires it once, as the page loads, before any component listens — so Rask.Web keeps it from boot and hands it to Window.OnBeforeInstallPrompt; the button's click calls Prompt() on it. Prompt() runs in WebAssembly — it needs the click's transient activation.

The kept BeforeInstallPromptEvent is spent once prompted; Prompt() answers MDN's PromptResponseObject, whose UserChoice is Accepted or Dismissed. The browser only offers it over HTTPS with a valid manifest + service worker, once per load. On the server host, Trigger.Install shows the same kept prompt from markup.

InstallPromptDemo.cs
using Microsoft.JSInterop;

namespace Rask.Site.Features;

/// <summary>
///     MDN's <c>beforeinstallprompt</c> from Rask.Web — show your own "Install app" button, and prompt from its click.
///     The browser fires the event once, as the page loads, before this component is there to hear it: Rask.Web keeps
///     it from boot and hands it to <c>Window.OnBeforeInstallPrompt</c>. The button only lights up once the browser
///     deems the app installable (valid manifest + service worker over HTTPS) and it isn't already installed.
/// </summary>
public sealed partial class InstallPromptDemo : Component
{
    private Types.BeforeInstallPromptEvent? _prompt;
    private IAsyncDisposable? _offered;
    private IAsyncDisposable? _installedNow;
    private bool _installed;
    private string _status = "checking…";

    protected override async Task OnFirstRender()
    {
        try
        {
            _offered = await Window.OnBeforeInstallPrompt(e =>
            {
                _prompt = e;
                _status = "installable — use the button";
            });
            _installedNow = await Window.OnAppInstalled(() =>
            {
                _installed = true;
                _prompt = null;
            });
        }
        catch (JSException ex)
        {
            _status = "check failed: " + ex.Message;
            return;
        }

        await Refresh();
    }

    // A standalone window is the one signal an installed app has on a fresh launch.
    private async Task Refresh()
    {
        try
        {
            _installed = _installed || await Window.MatchMedia("(display-mode: standalone)").Matches;
            _status = (_installed, _prompt is not null) switch
            {
                (true, _) => "running as an installed app",
                (false, true) => "installable — use the button",
                _ => "not installable yet (needs HTTPS + manifest + service worker, fired once per load)",
            };
        }
        catch (JSException ex)
        {
            _status = "check failed: " + ex.Message;
        }
    }

    // The prompt is spent once shown, accepted or not.
    private async Task Install()
    {
        if (_prompt is not { } prompt)
        {
            return;
        }

        try
        {
            var answer = await prompt.Prompt();
            _status = $"prompt outcome: {answer.UserChoice}";
        }
        catch (JSException ex)
        {
            _status = "prompt failed: " + ex.Message;
        }

        _prompt = null;
    }

    protected override Component? Render() =>
        Ui.Card[
                Div.Class("flex gap-2 flex-wrap mb-2")[
                    Ui.Button.Primary.Icon(Ui.IconName.ArrowDownTray)
                        .Id("install-button")
                        .Disabled(_prompt is null || _installed)
                        .OnClick(Install)["Install app"],
                    Ui.Button
                        .Id("install-refresh")
                        .OnClick(Refresh)["Re-check"]
                ],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("install-status")[_status]]
            ];

    protected override async Task OnUnmount()
    {
        if (_offered is not null)
        {
            await _offered.DisposeAsync();
        }

        if (_installedNow is not null)
        {
            await _installedNow.DisposeAsync();
        }
    }
}
Live result
Status: checking…

Wake lock

Keep the screen from dimming or locking with MDN's Screen Wake Lock API from Rask.Web — for timers, reading, or media. Every browser lets go of the lock when the page is hidden; Rask.Web asks for it again when the page is visible, so the sentinel holds until you release it.

Navigator.WakeLock.Request(WakeLockType.Screen) answers a kept WakeLockSentinel: Release() lets the screen sleep (Released turns true, OnRelease fires), and disposing of it lets the handle go. It needs no click, so it runs on either host.

WakeLockDemo.cs
using Microsoft.JSInterop;

namespace Rask.Site.Features;

/// <summary>
///     MDN's <c>WakeLock</c> from Rask.Web — keep the screen from dimming/locking, then release it. The browser lets
///     go of a screen lock whenever the page is hidden; Rask.Web asks for it again when the page is visible, so the
///     sentinel holds until <c>Release()</c> (here, toggling off — and on unmount).
/// </summary>
public sealed partial class WakeLockDemo : Component
{
    private Types.WakeLockSentinel? _sentinel;
    private string? _status;

    protected override Component? Render() =>
        Ui.Card[
                Div.Class("flex gap-2 flex-wrap mb-2")[
                    Ui.Button
                        .Variant(_sentinel is null ? Ui.ButtonVariant.Primary : Ui.ButtonVariant.Danger)
                        .Id("wakelock-toggle")
                        .OnClick(Toggle)[_sentinel is null ? "Keep screen awake" : "Release"]
                ],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("wakelock-status")[_status ?? "(idle)"]]
            ];

    private async Task Toggle()
    {
        try
        {
            if (_sentinel is not null)
            {
                await Release();
                _status = "Released — the screen can sleep again";
                return;
            }

            if (!await Navigator.WakeLock.IsSupported)
            {
                _status = "Wake lock not supported in this browser";
                return;
            }

            _sentinel = await Navigator.WakeLock.Request(Types.WakeLockType.Screen);
            _status = "Held — the screen will stay awake";
        }
        catch (JSException ex)
        {
            // Refused: a page that is not visible, a battery saver, or a permissions policy.
            _status = "Failed: " + ex.Message;
        }
    }

    // Release() is what lets the screen sleep; disposing only lets go of the handle.
    private async Task Release()
    {
        if (_sentinel is null)
        {
            return;
        }

        await _sentinel.Release();
        await _sentinel.DisposeAsync();
        _sentinel = null;
    }

    protected override Task OnUnmount() => Release();
}
Live result
Status: (idle)

Orientation

Read the screen orientation via Screen.Orientation and, for an installed or fullscreen app, lock it. Locking is usually rejected outside fullscreen and is often unsupported on desktop.

Screen.Orientation.Type and Angle read it; Lock(OrientationLockType)/Unlock() change it. Lock is WASM-only — it needs the live, usually fullscreen, document. Gate on IsSupported.

OrientationDemo.cs
using OrientationLockType = Rask.Web.Types.OrientationLockType;

namespace Rask.Site.Features;

/// <summary>
///     <c>Screen.Orientation</c> (Rask.Web) — read the current screen orientation and, for an installed or
///     fullscreen app, lock/unlock it. Locking is usually rejected outside fullscreen and is often
///     unsupported on desktop, so each call is wrapped in try/catch.
/// </summary>
public sealed partial class OrientationDemo : Component
{
    private string? _current;
    private string? _status;

    protected override Component? Render() =>
        Ui.Card[
                Div.Class("flex gap-2 flex-wrap mb-2")[
                    Ui.Button.Primary.Id("orientation-read").OnClick(Read)["Read current"],
                    Ui.Button
                        .Id("orientation-portrait")
                        .OnClick(() => Lock(OrientationLockType.Portrait))["Lock portrait"],
                    Ui.Button
                        .Id("orientation-landscape")
                        .OnClick(() => Lock(OrientationLockType.Landscape))["Lock landscape"],
                    Ui.Button.Red.Id("orientation-unlock").OnClick(Unlock)["Unlock"]
                ],
                Div.Class("text-sm text-zinc-500 dark:text-white/70 mb-1")[
                    "Current: ", Code.Id("orientation-current")[_current ?? "(read to see)"]],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("orientation-status")[_status ?? "(idle)"]]
            ];

    private async Task Read()
    {
        try
        {
            if (!await Screen.Orientation.IsSupported)
            {
                _current = "not supported";
                return;
            }

            _current = $"{await Screen.Orientation.Type} ({await Screen.Orientation.Angle}°)";
        }
        catch (Exception ex)
        {
            // Say so on the line the reader is actually looking at. Leaving _current at its "(read to
            // see)" placeholder made a thrown read identical to a click that never landed — for the
            // visitor and for the E2E alike, which asserts on this element. A demo whose button appears
            // to do nothing when it fails teaches the wrong lesson twice over.
            _current = "read failed";
            _status = "Failed: " + ex.Message;
        }
    }

    private async Task Lock(OrientationLockType to)
    {
        try
        {
            await Screen.Orientation.Lock(to);
            // Read back AFTER claiming the lock, and claim it last: Read owns _status on its failure
            // path, so setting the lock's status first let a failed read-back overwrite it with
            // "Failed: …" — reporting a lock that had in fact succeeded as one that had not. The read
            // failing is still visible, on the line that belongs to it.
            await Read();
            _status = $"Locked to {to}";
        }
        catch (Exception ex)
        {
            _status = $"Lock rejected (needs fullscreen?): {ex.Message}";
        }
    }

    private async Task Unlock()
    {
        try
        {
            await Screen.Orientation.Unlock();
            _status = "Unlocked";
        }
        catch (Exception ex)
        {
            _status = "Failed: " + ex.Message;
        }
    }
}
Live result
Current: (read to see)
Status: (idle)

Fullscreen

Present an element fullscreen with MDN's Fullscreen API: RequestFullscreen() on its ElementRef, Document.ExitFullscreen() to leave. WASM-only: requestFullscreen needs a live user gesture. Pairs with Orientation — locking the orientation generally requires fullscreen first.

await Document.FullscreenElement == _stage says which ref is showing (null for none). Gate on Document.FullscreenEnabled and catch JSException — a request without activation rejects.

FullscreenDemo.cs
using Microsoft.JSInterop;

namespace Rask.Site.Features;

/// <summary>MDN's Fullscreen API from Rask.Web — present one element ref fullscreen, then exit (orientation locking needs it first).</summary>
public sealed partial class FullscreenDemo : Component
{
    private readonly ElementRef<HTMLDivElement> _demo = new();
    private readonly ElementRef<HTMLDivElement> _stage = new();
    private string? _status;

    protected override Component? Render() =>
        Div.Ref(_demo).Class("bg-white dark:bg-zinc-800")[
            Ui.Card[
                Div
                    .Ref(_stage)
                    .Class("border rounded bg-zinc-50 dark:bg-zinc-900 flex items-center justify-center mb-2")
                    .Style("min-height: 8rem")[
                    Span.Class("text-zinc-500 dark:text-white/70 text-sm")["This box goes fullscreen."]
                ],
                Div.Class("flex gap-2 flex-wrap mb-2")[
                    Ui.Button.Primary.Id("fullscreen-enter").OnClick(() => Enter(_stage, "Box"))["Fullscreen this box"],
                    Ui.Button
                        .Id("fullscreen-page")
                        .OnClick(() => Enter(_demo, "Demo"))["Fullscreen the whole demo"],
                    Ui.Button.Red.Id("fullscreen-exit").OnClick(Exit)["Exit"]
                ],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("fullscreen-status")[_status ?? "(idle)"]]
            ]
        ];

    // requestFullscreen() rejects without a user gesture, so it runs in the click.
    private async Task Enter(ElementRef<HTMLDivElement> target, string what)
    {
        try
        {
            if (!await Document.FullscreenEnabled)
            {
                _status = "Fullscreen not available in this browser";
                return;
            }

            await target.RequestFullscreen();
            _status = await Document.FullscreenElement == target
                ? what + " is fullscreen — press Esc or Exit to leave"
                : "Fullscreen request did not take";
        }
        catch (JSException ex)
        {
            _status = "Failed: " + ex.Message;
        }
    }

    // exitFullscreen() rejects when nothing is fullscreen, so ask first.
    private async Task Exit()
    {
        try
        {
            if (await Document.FullscreenElement is not null)
            {
                await Document.ExitFullscreen();
            }

            _status = await Document.FullscreenElement is null ? "Exited fullscreen" : "Still fullscreen";
        }
        catch (JSException ex)
        {
            _status = "Failed: " + ex.Message;
        }
    }
}
Live result
This box goes fullscreen.
Status: (idle)

Picture-in-Picture

Float a video into an always-on-top miniplayer the user keeps visible while they scroll or switch tabs, via RequestPictureInPicture() on the video's ElementRef (MDN's Picture-in-Picture API). WASM-only: requestPictureInPicture needs a live user gesture. This demo synthesizes its video from an animated canvas (sibling scoped JS), so it needs no shipped media file.

RequestPictureInPicture() answers with the kept PictureInPictureWindow; Document.ExitPictureInPicture() brings it back. Gate on Document.PictureInPictureEnabled and catch JSException — a request without activation rejects.

using Microsoft.JSInterop;

namespace Rask.Site.Features;

/// <summary>MDN's Picture-in-Picture API from Rask.Web — float a canvas-fed <c>&lt;video&gt;</c> (sibling scoped TS) into a miniplayer.</summary>
public sealed partial class PictureInPictureDemo : Component
{
    private readonly ElementRef<HTMLVideoElement> _video = new();
    private Types.PictureInPictureWindow? _window;
    private string _status = "(idle)";

    protected override async Task OnFirstRender()
    {
        try
        {
            await Start(_video);
            _status = await Document.PictureInPictureEnabled
                ? "Playing — click \"Open miniplayer\""
                : "Picture-in-Picture not supported in this browser";
        }
        catch (JSException ex)
        {
            _status = "Setup failed: " + ex.Message;
        }

        StateHasChanged();
    }

    protected override Component? Render() =>
        Ui.Card[
                Video
                    .Ref(_video)
                    .Width(320)
                    .Height(180)
                    .Muted()
                    .PlaysInline()
                    .Controls()
                    .Class("rounded border mb-2 bg-slate-900"),
                Div.Class("flex gap-2 flex-wrap mb-2")[
                    Ui.Button.Primary.Id("pip-enter").OnClick(Enter)["Open miniplayer"],
                    Ui.Button.Red.Id("pip-exit").OnClick(Exit)["Exit"]
                ],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("pip-status")[_status]]
            ];

    // requestPictureInPicture() rejects without a user gesture, so it runs in the click.
    private async Task Enter()
    {
        try
        {
            await Release();
            _window = await _video.RequestPictureInPicture();
            _status = $"In a {await _window.Width}×{await _window.Height} miniplayer — drag it anywhere, then Exit to bring it back";
        }
        catch (JSException ex)
        {
            _status = "Failed: " + ex.Message;
        }
    }

    // exitPictureInPicture() rejects when nothing is in the miniplayer, so ask first.
    private async Task Exit()
    {
        try
        {
            if (await Document.PictureInPictureElement == _video)
            {
                await Document.ExitPictureInPicture();
            }

            await Release();
            _status = await Document.PictureInPictureElement == _video ? "Still in miniplayer" : "Back in the page";
        }
        catch (JSException ex)
        {
            _status = "Failed: " + ex.Message;
        }
    }

    protected override Task OnUnmount() => Release();

    private async Task Release()
    {
        if (_window is not null)
        {
            await _window.DisposeAsync();
            _window = null;
        }
    }
}
Live result
Status: (idle)

EyeDropper

Let the user pick a color from anywhere on screen with the system magnifier loupe, via EyeDropper (the EyeDropper API) — handy for a design tool or theme editor. WASM-only: open() needs a live user gesture, and it's Chromium-family only at the time of writing.

EyeDropper.Create() keeps a picker; Open() resolves with the picked SRGBHex (e.g. "#3366ff"). It rejects when the user cancels (Escape) or the browser has no EyeDropper — so try/catch.

EyeDropperDemo.cs
namespace Rask.Site.Features;

/// <summary>
///     <c>EyeDropper</c> (Rask.Web) — pick a color from anywhere on screen with the system loupe, then show
///     the picked swatch + hex. WASM-only: <c>open()</c> needs a live user gesture.
/// </summary>
public sealed partial class EyeDropperDemo : Component
{
    private string? _hex;
    private string _status = "(idle)";

    protected override Component? Render() =>
        Ui.Card[
                Div.Class("flex items-center gap-3 mb-2")[
                    Ui.Button.Primary.Icon(Ui.IconName.EyeDropper).Id("eyedropper-pick").OnClick(Pick)["Pick a color"],
                    _hex is null
                        ? Span.Class("text-zinc-500 dark:text-white/70 text-sm")["No color picked yet"]
                        : Div.Class("flex items-center gap-2")[
                            Span
                                .Id("eyedropper-swatch")
                                .Class("inline-block rounded border")
                                .Style($"width: 2rem; height: 2rem; background: {_hex}"),
                            Code.Id("eyedropper-hex")[_hex]
                        ]
                ],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("eyedropper-status")[_status]]
            ];

    // new EyeDropper().open(): rejects when the browser has no EyeDropper, or when the user presses Escape.
    private async Task Pick()
    {
        try
        {
            await using var dropper = await EyeDropper.Create();
            var color = await dropper.Open();
            _hex = color.SRGBHex;
            _status = "Picked " + _hex;
        }
        catch (Exception ex)
        {
            _status = "No color picked: " + ex.Message;
        }
    }
}
Live result
No color picked yet
Status: (idle)

Idle detection

Be notified when the user goes idle (no input for a threshold) or the screen locks, via IdleDetector (the Idle Detection API) — e.g. to auto-lock a session, pause a sync, or update presence in a collaborative app. WASM-only: the idle-detection permission needs a live gesture and the detector needs the live document.

IdleDetector.RequestPermission() must run from a gesture; IdleDetector.Create() keeps a detector, OnChange fires on each user/screen state change, and Start(new() { Threshold = 60_000 }) begins watching — the spec enforces a 60-second minimum. Dispose the detector to stop.

IdleDetectorDemo.cs
namespace Rask.Site.Features;

/// <summary>
///     <c>IdleDetector</c> (Rask.Web) — request the <c>idle-detection</c> permission from a gesture, then
///     watch for the user going idle or the screen locking. WASM-only: permission needs a live gesture and
///     the detector needs the live document.
/// </summary>
public sealed partial class IdleDetectorDemo : Component
{
    private Types.IdleDetector? _idle;
    private IAsyncDisposable? _watch;
    private string _user = "active";
    private string _screen = "unlocked";
    private string _status = "(idle)";

    protected override Component? Render() =>
        Ui.Card[
                Ui.Button.Primary.Class("mb-3").Id("idle-start").OnClick(Start)["Start watching (60s threshold)"],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["User: ", Code.Id("idle-user")[_user]],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["Screen: ", Code.Id("idle-screen")[_screen]],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("idle-status")[_status]]
            ];

    private async Task Start()
    {
        if (_idle is not null)
        {
            return;
        }

        try
        {
            if (await IdleDetector.RequestPermission() is not Types.PermissionState.Granted)
            {
                _status = "Permission denied";
                return;
            }

            // The detector's `change` event: read both states each time either one moves.
            var idle = await IdleDetector.Create();
            _idle = idle;
            _watch = await idle.OnChange(async () =>
            {
                _user = await idle.UserState is Types.UserIdleState.Idle ? "idle" : "active";
                _screen = await idle.ScreenState is Types.ScreenIdleState.Locked ? "locked" : "unlocked";
            });
            await idle.Start(new() { Threshold = 60_000 });
            _status = "Watching — stop interacting for 60s to go idle";
        }
        catch (Exception ex)
        {
            _status = "Failed: " + ex.Message;
        }
    }

    protected override async Task OnUnmount()
    {
        if (_watch is not null)
        {
            await _watch.DisposeAsync();
        }

        if (_idle is not null)
        {
            await _idle.DisposeAsync();
        }
    }
}
Live result
User: active
Screen: unlocked
Status: (idle)

Camera & microphone

Capture the camera, microphone, or screen and show it in a <video> via MDN's MediaDevices in Rask.Web (getUserMedia / getDisplayMedia) — for photo capture, video calls, or screen recording. WASM-only: capture needs a live user gesture and a secure context. Stop each track to release the hardware (the camera indicator turns off), then dispose of the stream.

Navigator.MediaDevices.GetUserMedia(new() { Video = new() }) asks for the camera — an empty constraints object is MDN's `video: true`, and leaving Video null does not ask. It answers with a kept MediaStream: SetSrcObject on an ElementRef<HTMLVideoElement> shows it, Play() starts it. Gate on IsSupported and catch JSException — a denied request rejects.

MediaDevicesDemo.cs
using Microsoft.JSInterop;

namespace Rask.Site.Features;

/// <summary>
///     MDN's <c>MediaDevices</c> from Rask.Web — capture the camera/microphone (or the screen) and show it in a
///     <c>&lt;video&gt;</c>. The stream is a live object the browser holds: stopping its tracks releases the hardware
///     (the camera indicator turns off), and disposing of it lets the browser drop it.
/// </summary>
public sealed partial class MediaDevicesDemo : Component
{
    private readonly ElementRef<HTMLVideoElement> _video = new();
    private Types.MediaStream? _stream;
    private string _status = "(idle)";

    protected override Component? Render() =>
        Ui.Card[
                Video
                    .Ref(_video)
                    .Width(320)
                    .Height(240)
                    .Muted()
                    .PlaysInline()
                    .Class("rounded border mb-2 bg-slate-900 block"),
                Div.Class("flex gap-2 flex-wrap mb-2")[
                    Ui.Button.Primary.Icon(Ui.IconName.VideoCamera).Id("media-start").OnClick(StartCamera)["Start camera"],
                    Ui.Button.Icon(Ui.IconName.ComputerDesktop).Id("media-screen").OnClick(ShareScreen)["Share screen"],
                    Ui.Button.Red
                        .Id("media-stop")
                        .Disabled(_stream is null)
                        .OnClick(Stop)["Stop"]
                ],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("media-status")[_status]]
            ];

    // An empty constraints object asks for the camera with whatever the browser picks; `Video = null` would not ask.
    private Task StartCamera() =>
        Capture(() => Navigator.MediaDevices.GetUserMedia(new() { Video = new() }), "Camera live");

    private Task ShareScreen() => Capture(() => Navigator.MediaDevices.GetDisplayMedia(), "Screen sharing");

    private async Task Capture(Func<ValueTask<Types.MediaStream>> request, string okStatus)
    {
        try
        {
            if (!await Navigator.MediaDevices.IsSupported)
            {
                _status = "Media capture not supported in this browser";
                return;
            }

            await Release();
            _stream = await request();
            await _video.SetSrcObject(_stream);
            await _video.Play();
            _status = okStatus;
        }
        catch (JSException ex)
        {
            // A denied or dismissed prompt rejects (NotAllowedError), as does a page with no camera (NotFoundError).
            _status = "Failed: " + ex.Message;
        }
    }

    private async Task Stop()
    {
        await Release();
        _status = "Stopped — hardware released";
    }

    // Stopping every track is what turns the camera off; disposing only lets go of the handles.
    private async Task Release()
    {
        if (_stream is null)
        {
            return;
        }

        foreach (var track in await _stream.GetTracks())
        {
            await track.Stop();
            await track.DisposeAsync();
        }

        await _video.SetSrcObject(null);
        await _stream.DisposeAsync();
        _stream = null;
    }

    protected override Task OnUnmount() => Release();
}
Live result
Status: (idle)

Web Serial

Talk to a serial device — an Arduino or microcontroller, a GPS, a USB-to-serial adapter — straight from C# via MDN's Web Serial API in Rask.Web: pick a port, write a line, and watch inbound bytes stream into the log. WASM-only: requestPort() needs a live user gesture, and it's Chromium-family only at the time of writing.

await Navigator.Serial.RequestPort() shows the browser port chooser and keeps the SerialPort it answers (dismissing the chooser throws); Open(new() { BaudRate = 9600 }) opens it. port.Readable.GetReader() reads inbound chunks with Read<byte[]>() until Done, and port.Writable.GetWriter() writes a byte[] with Write(bytes). Cancel the reader, Close() the port, then dispose it to release it. Gate on await Navigator.Serial.IsSupported.

SerialDemo.cs
using System.Globalization;
using System.Text;
using Microsoft.JSInterop;

namespace Rask.Site.Features;

/// <summary>
///     MDN's Web Serial API from Rask.Web — talk to a serial device (Arduino / microcontroller, USB-to-serial
///     adapter) from C# in the browser: pick a port from a gesture, write a line, and watch inbound bytes stream into
///     the log. WASM-only: <c>requestPort()</c> needs a live user gesture, and it's Chromium-family only at the time
///     of writing.
/// </summary>
public sealed partial class SerialDemo : Component
{
    private Types.SerialPort? _port;
    private Types.ReadableStreamDefaultReader? _reader;
    private Task _reading = Task.CompletedTask;
    private IAsyncDisposable? _unplugged;
    private int _baudRate = 9600;
    private string _outgoing = string.Empty;
    private readonly List<string> _log = [];
    private string _status = "(idle)";

    protected override Component? Render() =>
        Ui.Card[
                Div.Class("flex gap-2 flex-wrap items-center mb-2")[
                    Ui.Input.Value(_baudRate.ToString(CultureInfo.InvariantCulture)).Label("Baud")
                        .Id("serial-baud")
                        .Type(InputType.Number)
                        .Disabled(_port is not null)
                        .OnInput(v => _baudRate = int.TryParse(v, CultureInfo.InvariantCulture, out var baud) ? baud : 0),
                    Ui.Button.Primary.Icon(Ui.IconName.CubeTransparent)
                        .Id("serial-connect")
                        .Disabled(_port is not null)
                        .OnClick(Connect)["Connect"],
                    Ui.Button.Red
                        .Id("serial-disconnect")
                        .Disabled(_port is null)
                        .OnClick(Disconnect)["Disconnect"]
                ],
                Div.Class("flex items-stretch gap-2 mb-2")[
                    Ui.Input.Value(_outgoing).Label("Line to send")
                        .Id("serial-outgoing")
                        .Placeholder("Line to send")
                        .Disabled(_port is null)
                        .OnInput(v => _outgoing = v),
                    Ui.Button.Primary
                        .Id("serial-send")
                        .Disabled(_port is null)
                        .OnClick(Send)["Send"]
                ],
                Pre
                    .Class("text-sm bg-slate-900 text-slate-100 rounded p-2 mb-2")
                    .Id("serial-log")
                    .Style("min-height: 6rem; max-height: 12rem; overflow: auto")[
                    _log.Count == 0 ? "(no data yet)" : string.Join("\n", _log)],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("serial-status")[_status]]
            ];

    // navigator.serial.requestPort() shows the chooser (dismissing it rejects), then port.open({ baudRate }).
    private async Task Connect()
    {
        try
        {
            if (!await Navigator.Serial.IsSupported)
            {
                _status = "Web Serial not supported in this browser (Chromium-family only)";
                return;
            }

            _port = await Navigator.Serial.RequestPort();
            await _port.Open(new() { BaudRate = _baudRate });
            _unplugged = await _port.OnDisconnect(async () =>
            {
                await Release();
                _status = "Device disconnected";
            });
            _reader = await _port.Readable.GetReader();
            _reading = ReadLoop(_reader);
            _status = $"Connected at {_baudRate} baud";
        }
        catch (JSException ex)
        {
            await Release();
            _status = "Failed: " + ex.Message;
        }
    }

    // reader.read() resolves with each chunk the device sends, and with done once the reader is cancelled.
    private async Task ReadLoop(Types.ReadableStreamDefaultReader reader)
    {
        try
        {
            while (await reader.Read<byte[]>() is { Done: false } chunk)
            {
                _log.Add(Encoding.UTF8.GetString(chunk.Value));
                if (_log.Count > 100)
                {
                    _log.RemoveRange(0, _log.Count - 100);
                }

                StateHasChanged();
            }
        }
        catch (JSException ex)
        {
            _status = "Read stopped: " + ex.Message;
            StateHasChanged();
        }
    }

    // A writer locks the port's writable stream, so take one per line and let it go again.
    private async Task Send()
    {
        if (_port is null)
        {
            return;
        }

        try
        {
            await using var writer = await _port.Writable.GetWriter();
            try
            {
                await writer.Write(Encoding.UTF8.GetBytes(_outgoing + "\n"));
            }
            finally
            {
                await writer.ReleaseLock();
            }

            _status = "Sent: " + _outgoing;
            _outgoing = string.Empty;
        }
        catch (JSException ex)
        {
            _status = "Send failed: " + ex.Message;
        }
    }

    private async Task Disconnect()
    {
        await Release();
        _status = "Disconnected — port released";
    }

    // Cancel the read (it ends the loop), unlock the stream, then close the port and let every handle go.
    private async Task Release()
    {
        var port = _port;
        var reader = _reader;
        _port = null;
        _reader = null;
        try
        {
            if (reader is not null)
            {
                await reader.Cancel();
                await _reading;
                await reader.ReleaseLock();
            }

            if (port is not null)
            {
                await port.Close();
            }
        }
        catch (JSException)
        {
            // An unplugged port has already closed its streams; there is nothing left to release on its side.
        }

        if (_unplugged is not null)
        {
            await _unplugged.DisposeAsync();
            _unplugged = null;
        }

        if (reader is not null)
        {
            await reader.DisposeAsync();
        }

        if (port is not null)
        {
            await port.DisposeAsync();
        }
    }

    protected override Task OnUnmount() => Release();
}
Live result
(no data yet)
Status: (idle)

WebUSB

Pair with and drive a USB device — custom hardware, a dev board, an instrument — straight from C# via MDN's WebUSB API in Rask.Web: show its descriptor, open it, claim an interface, and run bulk / interrupt / control transfers. WASM-only: requestDevice() needs a live user gesture, and it's Chromium-family only at the time of writing.

await Navigator.Usb.RequestDevice(new() { Filters = [] }) shows the browser device chooser and keeps the USBDevice it answers (dismissing it throws). Transfer payloads cross as byte[] — TransferOut(endpoint, bytes); dispose the device to release it. Actual transfers are device-specific, so the demo shows discovery + lifecycle. Gate on await Navigator.Usb.IsSupported.

UsbDemo.cs
using System.Globalization;

namespace Rask.Site.Features;

/// <summary>
///     MDN's WebUSB API from Rask.Web — pair with a USB device from a gesture and read its descriptor (vendor /
///     product / manufacturer / serial), then open and release it. WASM-only: requestDevice() needs a live user
///     gesture, and it's Chromium-family only at the time of writing. Actual data transfer (claim an interface,
///     transferIn/Out) is device-specific, so this demo shows discovery + lifecycle.
/// </summary>
public sealed partial class UsbDemo : Component
{
    private Types.USBDevice? _device;
    private IAsyncDisposable? _unplugged;
    private UsbInfo? _info;
    private bool _open;
    private string _status = "(idle)";

    private sealed record UsbInfo(int VendorId, int ProductId, string? ManufacturerName, string? ProductName, string? SerialNumber);

    protected override Component? Render() =>
        Ui.Card[
                Div.Class("flex gap-2 flex-wrap mb-2")[
                    Ui.Button.Primary.Icon(Ui.IconName.CubeTransparent).Id("usb-request").OnClick(RequestDevice)["Pair device"],
                    Ui.Button
                        .Id("usb-open")
                        .Disabled(_device is null || _open)
                        .OnClick(Open)["Open"],
                    Ui.Button.Red
                        .Id("usb-close")
                        .Disabled(_device is null)
                        .OnClick(Release)["Release"]
                ],
                _info is null
                    ? Div.Class("text-sm text-zinc-500 dark:text-white/70")["No device paired."]
                    : Dl.Class("grid grid-cols-12 gap-4 text-sm mb-2").Id("usb-info")[
                        Dt.Class("col-span-5 sm:col-span-4 text-zinc-500 dark:text-white/70")["Vendor ID"],
                        Dd.Class("col-span-7 sm:col-span-8")[Code[Hex(_info.VendorId)]],
                        Dt.Class("col-span-5 sm:col-span-4 text-zinc-500 dark:text-white/70")["Product ID"],
                        Dd.Class("col-span-7 sm:col-span-8")[Code[Hex(_info.ProductId)]],
                        Dt.Class("col-span-5 sm:col-span-4 text-zinc-500 dark:text-white/70")["Manufacturer"],
                        Dd.Class("col-span-7 sm:col-span-8")[_info.ManufacturerName ?? "—"],
                        Dt.Class("col-span-5 sm:col-span-4 text-zinc-500 dark:text-white/70")["Product"],
                        Dd.Class("col-span-7 sm:col-span-8")[_info.ProductName ?? "—"],
                        Dt.Class("col-span-5 sm:col-span-4 text-zinc-500 dark:text-white/70")["Serial"],
                        Dd.Class("col-span-7 sm:col-span-8")[_info.SerialNumber ?? "—"]
                    ],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("usb-status")[_status]]
            ];

    private static string Hex(int value) => "0x" + value.ToString("x4", CultureInfo.InvariantCulture);

    // navigator.usb.requestDevice({ filters: [] }) offers every device; dismissing the chooser rejects.
    private async Task RequestDevice()
    {
        try
        {
            if (!await Navigator.Usb.IsSupported)
            {
                _status = "WebUSB not supported in this browser (Chromium-family only)";
                return;
            }

            await CloseInternal();
            _device = await Navigator.Usb.RequestDevice(new() { Filters = [] });
            _info = await Describe(_device);
            _unplugged ??= await Navigator.Usb.OnDisconnect(Unplugged);
            _status = "Paired";
        }
        catch (Exception ex)
        {
            _status = "Failed: " + ex.Message;
        }
    }

    private static async Task<UsbInfo> Describe(Types.USBDevice device) =>
        new(await device.VendorId, await device.ProductId, await device.ManufacturerName, await device.ProductName,
            await device.SerialNumber);

    private async Task Open()
    {
        if (_device is null)
        {
            return;
        }

        try
        {
            await _device.Open();
            _open = true;
            _status = "Opened — ready for device-specific transfers";
        }
        catch (Exception ex)
        {
            _status = "Open failed: " + ex.Message;
        }
    }

    // navigator.usb's `disconnect` fires for any device, so ask getDevices() whether the paired one is still there.
    private async Task Unplugged()
    {
        if (_info is null)
        {
            return;
        }

        var present = false;
        foreach (var device in await Navigator.Usb.GetDevices())
        {
            await using (device)
            {
                present |= await Describe(device) == _info;
            }
        }

        if (!present)
        {
            _open = false; // an unplugged device is closed already
            await CloseInternal();
            _status = "Device disconnected";
        }
    }

    private async Task Release()
    {
        await CloseInternal();
        _status = "Released — device closed";
    }

    private async Task CloseInternal()
    {
        if (_device is not null)
        {
            if (_open)
            {
                await _device.Close();
            }

            await _device.DisposeAsync();
            _device = null;
            _info = null;
            _open = false;
        }
    }

    protected override async Task OnUnmount()
    {
        await CloseInternal();
        if (_unplugged is not null)
        {
            await _unplugged.DisposeAsync();
        }
    }
}
Live result
No device paired.
Status: (idle)

WebHID

Talk to a human-interface device that no higher-level API covers — a gamepad with custom reports, a keyboard with extra keys, simulation controls, point-of-sale hardware — via MDN's WebHID API in Rask.Web: open it, send output / feature reports, and subscribe to its live input-report stream. WASM-only: requestDevice() needs a live user gesture, and it's Chromium-family only at the time of writing.

await Navigator.Hid.RequestDevice(new() { Filters = [] }) shows the browser chooser and keeps each granted HIDDevice (none if dismissed). Open() a device, then OnInputReport hands each report to your handler, which re-renders the component; dispose the subscription and the device to release. Report payloads cross as byte[]. Gate on await Navigator.Hid.IsSupported.

HidDemo.cs
using System.Globalization;

namespace Rask.Site.Features;

/// <summary>
///     MDN's WebHID API from Rask.Web — pair with a HID device from a gesture, open it, and watch its input-report
///     stream live. WASM-only: requestDevice() needs a live user gesture, and it's Chromium-family only at the time
///     of writing. Move/press the device after "Watch" to see reports arrive.
/// </summary>
public sealed partial class HidDemo : Component
{
    private Types.HIDDevice? _device;
    private HidInfo? _info;
    private IAsyncDisposable? _watch;
    private IAsyncDisposable? _unplugged;
    private int _reportCount;
    private string _lastReport = "—";
    private string _status = "(idle)";

    private sealed record HidInfo(int VendorId, int ProductId, string ProductName);

    protected override Component? Render() =>
        Ui.Card[
                Div.Class("flex gap-2 flex-wrap mb-2")[
                    Ui.Button.Primary.Icon(Ui.IconName.CubeTransparent).Id("hid-request").OnClick(RequestDevice)["Pair device"],
                    Ui.Button
                        .Id("hid-watch")
                        .Disabled(_device is null || _watch is not null)
                        .OnClick(Watch)["Open & watch"],
                    Ui.Button.Red
                        .Id("hid-close")
                        .Disabled(_device is null)
                        .OnClick(Release)["Release"]
                ],
                _info is null
                    ? Div.Class("text-sm text-zinc-500 dark:text-white/70")["No device paired."]
                    : Dl.Class("grid grid-cols-12 gap-4 text-sm mb-2").Id("hid-info")[
                        Dt.Class("col-span-5 sm:col-span-4 text-zinc-500 dark:text-white/70")["Vendor ID"],
                        Dd.Class("col-span-7 sm:col-span-8")[Code["0x" + _info.VendorId.ToString("x4", CultureInfo.InvariantCulture)]],
                        Dt.Class("col-span-5 sm:col-span-4 text-zinc-500 dark:text-white/70")["Product ID"],
                        Dd.Class("col-span-7 sm:col-span-8")[Code["0x" + _info.ProductId.ToString("x4", CultureInfo.InvariantCulture)]],
                        Dt.Class("col-span-5 sm:col-span-4 text-zinc-500 dark:text-white/70")["Product"],
                        Dd.Class("col-span-7 sm:col-span-8")[_info.ProductName]
                    ],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["Reports: ", Code.Id("hid-count")[_reportCount]],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["Last: ", Code.Id("hid-last")[_lastReport]],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("hid-status")[_status]]
            ];

    // navigator.hid.requestDevice({ filters: [] }) offers every device and answers the ones granted (none if dismissed).
    private async Task RequestDevice()
    {
        try
        {
            if (!await Navigator.Hid.IsSupported)
            {
                _status = "WebHID not supported in this browser (Chromium-family only)";
                return;
            }

            await CloseInternal();
            var granted = await Navigator.Hid.RequestDevice(new() { Filters = [] });
            foreach (var extra in granted.Skip(1))
            {
                await extra.DisposeAsync();
            }

            _device = granted.FirstOrDefault();
            _info = _device is null ? null : await Describe(_device);
            _unplugged ??= await Navigator.Hid.OnDisconnect(Unplugged);
            _status = _device is null ? "No device selected" : "Paired — click Open & watch";
        }
        catch (Exception ex)
        {
            _status = "Failed: " + ex.Message;
        }
    }

    private static async Task<HidInfo> Describe(Types.HIDDevice device) =>
        new(await device.VendorId, await device.ProductId, await device.ProductName);

    // Every `inputreport` event re-renders this component with the report's id and bytes.
    private async Task Watch()
    {
        if (_device is null || _watch is not null)
        {
            return;
        }

        try
        {
            await _device.Open();
            _watch = await _device.OnInputReport(report =>
            {
                _reportCount++;
                _lastReport = $"#{report.ReportId} [{Convert.ToHexString(report.Data)}]";
            });
            _status = "Watching — interact with the device";
        }
        catch (Exception ex)
        {
            _status = "Open/watch failed: " + ex.Message;
        }
    }

    // navigator.hid's `disconnect` fires for any device, so ask getDevices() whether the paired one is still there.
    private async Task Unplugged()
    {
        if (_info is null)
        {
            return;
        }

        var present = false;
        foreach (var device in await Navigator.Hid.GetDevices())
        {
            await using (device)
            {
                present |= await Describe(device) == _info;
            }
        }

        if (!present)
        {
            await CloseInternal();
            _status = "Device disconnected";
        }
    }

    private async Task Release()
    {
        await CloseInternal();
        _status = "Released — device closed";
    }

    private async Task CloseInternal()
    {
        if (_watch is not null)
        {
            await _watch.DisposeAsync();
            _watch = null;
        }

        if (_device is not null)
        {
            if (await _device.Opened)
            {
                await _device.Close();
            }

            await _device.DisposeAsync();
            _device = null;
            _info = null;
        }
    }

    protected override async Task OnUnmount()
    {
        await CloseInternal();
        if (_unplugged is not null)
        {
            await _unplugged.DisposeAsync();
        }
    }
}
Live result
No device paired.
Reports: 0
Last: —
Status: (idle)

Web Bluetooth

Pair with a Bluetooth Low Energy device and talk to its GATT services from C# — connect, read / write characteristics, and subscribe to notifications (heart-rate monitors, thermometers, fitness sensors, custom hardware) — via MDN's Web Bluetooth API in Rask.Web. WASM-only: requestDevice() needs a live user gesture, and it's Chromium-family only at the time of writing.

await Navigator.Bluetooth.RequestDevice(new() { Filters = [new() { Services = ["battery_service"] }] }) shows the chooser and keeps the BluetoothDevice (dismissing it throws). Gatt.Connect(), then GetPrimaryService → GetCharacteristic → ReadValue() / WriteValueWithResponse(bytes) / OnCharacteristicValueChanged. This demo reads the standard Battery Service. Values cross as byte[]; Gatt.Disconnect() then dispose the device. Gate on await Navigator.Bluetooth.IsSupported.

BluetoothDemo.cs
using Microsoft.JSInterop;

namespace Rask.Site.Features;

/// <summary>
///     MDN's Web Bluetooth API from Rask.Web — pair with a Bluetooth Low Energy device, connect to its GATT server,
///     and read the standard Battery Service's <c>battery_level</c> (0–100%). WASM-only: requestDevice() needs a live
///     user gesture, and it's Chromium-family only at the time of writing.
/// </summary>
public sealed partial class BluetoothDemo : Component
{
    private Types.BluetoothDevice? _device;
    private IAsyncDisposable? _disconnectWatch;
    private string? _name;
    private string _battery = "—";
    private string _status = "(idle)";

    protected override Component? Render() =>
        Ui.Card[
                Div.Class("flex gap-2 flex-wrap mb-2")[
                    Ui.Button.Primary.Icon(Ui.IconName.Signal).Id("bt-request").OnClick(PairAndRead)["Pair & read battery"],
                    Ui.Button.Red
                        .Id("bt-disconnect")
                        .Disabled(_device is null)
                        .OnClick(Disconnect)["Disconnect"]
                ],
                _name is null
                    ? Div.Class("text-sm text-zinc-500 dark:text-white/70")["No device paired."]
                    : Dl.Class("grid grid-cols-12 gap-4 text-sm mb-2").Id("bt-info")[
                        Dt.Class("col-span-5 sm:col-span-4 text-zinc-500 dark:text-white/70")["Device"],
                        Dd.Class("col-span-7 sm:col-span-8")[_name],
                        Dt.Class("col-span-5 sm:col-span-4 text-zinc-500 dark:text-white/70")["Battery"],
                        Dd.Class("col-span-7 sm:col-span-8")[Code.Id("bt-battery")[_battery]]
                    ],
                Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("bt-status")[_status]]
            ];

    // navigator.bluetooth.requestDevice() shows the chooser (dismissing it rejects), then the GATT server walks
    // service → characteristic → readValue(), whose DataView comes back as bytes.
    private async Task PairAndRead()
    {
        try
        {
            if (!await Navigator.Bluetooth.IsSupported)
            {
                _status = "Web Bluetooth not supported in this browser (Chromium-family only)";
                return;
            }

            await CloseInternal();
            _device = await Navigator.Bluetooth.RequestDevice(new()
            {
                Filters = [new() { Services = ["battery_service"] }],
                OptionalServices = ["battery_service"]
            });
            _name = await _device.Name ?? await _device.Id;
            _disconnectWatch = await _device.OnGattServerDisconnected(async () =>
            {
                await CloseInternal();
                _status = "Device disconnected";
            });

            await using var server = await _device.Gatt.Connect();
            await using var battery = await server.GetPrimaryService("battery_service");
            await using var level = await battery.GetCharacteristic("battery_level");
            var bytes = await level.ReadValue();
            _battery = bytes.Length > 0 ? $"{bytes[0]}%" : "(empty)";
            _status = "Connected — battery read";
        }
        catch (JSException ex)
        {
            _status = "Failed: " + ex.Message;
        }
    }

    // Stop listening first, so dropping the link ourselves isn't reported as the device going away.
    private async Task Disconnect()
    {
        await StopWatching();
        if (_device is not null)
        {
            await _device.Gatt.Disconnect();
        }

        await CloseInternal();
        _status = "Disconnected";
    }

    private async Task StopWatching()
    {
        if (_disconnectWatch is not null)
        {
            await _disconnectWatch.DisposeAsync();
            _disconnectWatch = null;
        }
    }

    private async Task CloseInternal()
    {
        await StopWatching();

        if (_device is not null)
        {
            await _device.DisposeAsync();
            _device = null;
            _name = null;
            _battery = "—";
        }
    }

    protected override Task OnUnmount() => CloseInternal();
}
Live result
No device paired.
Status: (idle)

Confirm