Browser APIs — reference & live demos
Every typed browser wrapper, and the MDN calls from Rask.Web that replaced the rest, with a runnable demo showing its C# source beside the live result.
‹ Back to Browser APIs
API reference — live demos
Every demo below runs live and identically on both transports. Each demo shows its C# source beside the running result (some are device/permission-dependent and no-op in a headless or desktop browser — try them on a phone). The WASM-only device APIs (Serial, USB, HID, Bluetooth) and the installation/PWA APIs live in the Mobile & PWA guide.
Storage & persistence
localStorage / sessionStorage — await LocalStorage.SetItem(key, value) and await LocalStorage.GetItem(key), from Rask.Web.
namespace Rask.Site.Features;
/// <summary>MDN's <c>localStorage</c>, from Rask.Web — a round-trip, identical on Server and WASM.</summary>
public sealed partial class StorageDemo : Component
{
private const string StorageKey = "rask.browser.storage";
private string _input = string.Empty;
private string? _read;
private string? _status;
protected override Component? Render() =>
Ui.Card[
Div.Class("mb-2 flex gap-2")[
Ui.Input.Value(_input).Label("Value to persist")
.Id("storage-input")
.Placeholder("Value to persist")
.OnInput(v => _input = v),
Ui.Button.Primary.Id("storage-set").OnClick(Set)["Set"],
Ui.Button.Id("storage-read").OnClick(Read)["Read"],
Ui.Button.Red.Id("storage-remove").OnClick(Remove)["Remove"]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Last read: ", Code.Id("storage-read-value")[_read ?? "(null)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("storage-status")[_status ?? "(idle)"]]
];
private async Task Set()
{
try
{
await LocalStorage.SetItem(StorageKey, _input);
_status = $"Stored: {_input}";
}
catch (Exception ex) { _status = "Set failed: " + ex.Message; }
}
private async Task Read()
{
try
{
_read = await LocalStorage.GetItem(StorageKey);
_status = $"Read (localStorage holds {await LocalStorage.Length} key(s))";
}
catch (Exception ex) { _status = "Read failed: " + ex.Message; }
}
private async Task Remove()
{
try
{
await LocalStorage.RemoveItem(StorageKey);
_read = null;
_status = "Removed";
}
catch (Exception ex) { _status = "Remove failed: " + ex.Message; }
}
}(null)(idle)IIndexedDb — a persistent, asynchronous key/value store, far larger than localStorage and non-blocking. Holds
text (Set/Get) or raw bytes (SetBytes/GetBytes, stored as a real Uint8Array).
using Rask.Core.Browser;
namespace Rask.Site.Features;
/// <summary>
/// <see cref="IIndexedDb" /> — a persistent, async key/value store backed by IndexedDB (larger than
/// localStorage). Set a value, read it back, and list keys; the data survives a reload.
/// </summary>
public sealed partial class IndexedDbDemo(IIndexedDb indexedDb) : Component
{
private IKeyValueStore? _store;
private string _key = "greeting";
private string _value = "hello from IndexedDB";
private string? _read;
private string? _keys;
private string? _status;
private async Task<IKeyValueStore> Store() => _store ??= await indexedDb.OpenStore("rask-demo");
protected override Component? Render() =>
Ui.Card[
Div.Class("grid grid-cols-12 gap-4 mb-2")[
Div.Class("col-span-12 sm:col-span-4")[
Ui.Input
.Value(_key)
.Label("Key")
.Id("idb-key")
.OnInput(v => _key = v)],
Div.Class("col-span-12 sm:col-span-8")[
Ui.Input
.Value(_value)
.Label("Value")
.Id("idb-value")
.OnInput(v => _value = v)]
],
Div.Class("flex gap-2 flex-wrap items-center mb-2")[
Ui.Button.Primary.Id("idb-set").OnClick(Set)["Set"],
Ui.Button.Id("idb-get").OnClick(Get)["Get"],
Ui.Button.Id("idb-keys").OnClick(Keys)["List keys"],
Ui.Button.Red.Id("idb-clear").OnClick(Clear)["Clear"]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Read: ", Code.Id("idb-read")[_read ?? "(none)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Keys: ", Code.Id("idb-keys-value")[_keys ?? "(none)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("idb-status")[_status ?? "(idle)"]]
];
private async Task Set()
{
try { await (await Store()).Set(_key, _value); _status = "Stored"; }
catch (Exception ex) { _status = "Failed: " + ex.Message; }
}
private async Task Get()
{
try { _read = await (await Store()).Get(_key) ?? "(not found)"; _status = "Read"; }
catch (Exception ex) { _status = "Failed: " + ex.Message; }
}
private async Task Keys()
{
try { _keys = string.Join(", ", await (await Store()).Keys()); _status = "Listed"; }
catch (Exception ex) { _status = "Failed: " + ex.Message; }
}
private async Task Clear()
{
try { await (await Store()).Clear(); _read = _keys = null; _status = "Cleared"; }
catch (Exception ex) { _status = "Failed: " + ex.Message; }
}
}(none)(none)(idle)Cookies — await Document.Cookie reads every non-HttpOnly cookie as "a=1; b=2", and
await Document.SetCookie("theme=dark; path=/") writes one, from Rask.Web. In a secure context MDN's
async CookieStore.Get / Set / Delete reads and writes one cookie by name.
using Microsoft.JSInterop;
namespace Rask.Site.Features;
/// <summary>
/// MDN's <c>document.cookie</c> from Rask.Web — read/write non-<c>HttpOnly</c> cookies, identical on Server and
/// WASM.
/// </summary>
public sealed partial class CookiesDemo : Component
{
private const string Name = "rask_browser_cookie";
private string _input = "vanilla";
private string? _read;
private string? _status;
protected override Component? Render() =>
Ui.Card[
Div.Class("flex items-stretch gap-2 mb-2")[
Ui.Input.Value(_input).Label("Cookie value")
.Id("cookie-input")
.Placeholder("Cookie value")
.OnInput(v => _input = v),
Ui.Button.Primary.Id("cookie-set").OnClick(Set)["Set"],
Ui.Button.Id("cookie-get").OnClick(Get)["Get"],
Ui.Button.Red.Id("cookie-delete").OnClick(Delete)["Delete"]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Value: ", Code.Id("cookie-read-value")[_read ?? "(null)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("cookie-status")[_status ?? "(idle)"]]
];
// document.cookie = "name=value; …": one cookie per write, its value URI-encoded as MDN recommends.
private async Task Set()
{
try
{
await Document.SetCookie($"{Name}={Uri.EscapeDataString(_input)}; max-age=3600; path=/; samesite=lax");
_status = $"Set: {_input}";
}
catch (JSException ex) { _status = "Set failed: " + ex.Message; }
}
// document.cookie reads every cookie as "a=1; b=2", so find ours by name.
private async Task Get()
{
try
{
_read = Find(await Document.Cookie, Name);
_status = _read is null ? "Not present" : "Read";
}
catch (JSException ex) { _status = "Get failed: " + ex.Message; }
}
// A cookie is deleted by writing it again, already expired, on the same path.
private async Task Delete()
{
try
{
await Document.SetCookie($"{Name}=; max-age=0; path=/");
_read = null;
_status = "Deleted";
}
catch (JSException ex) { _status = "Delete failed: " + ex.Message; }
}
private static string? Find(string cookies, string name)
{
foreach (var pair in cookies.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
var at = pair.IndexOf('=', StringComparison.Ordinal);
if (at > 0 && pair.AsSpan(0, at).SequenceEqual(name))
{
return Uri.UnescapeDataString(pair[(at + 1)..]);
}
}
return null;
}
}(null)(idle)Storage estimate — await Navigator.Storage.Estimate() (Quota / Usage bytes), Persisted() and Persist(),
from Rask.Web; the origin's storage budget, and asking for it to survive eviction.
using System.Globalization;
using Microsoft.JSInterop;
namespace Rask.Site.Features;
/// <summary>MDN's <c>StorageManager.estimate()</c> from Rask.Web — read the origin's storage quota and usage.</summary>
public sealed partial class StorageEstimateDemo : Component
{
private string? _value;
private string? _status;
protected override Component? Render() =>
Ui.Card[
Ui.Button.Class("mb-2")
.Id("storage-est-read")
.OnClick(Read)["Estimate storage"],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Budget: ", Code.Id("storage-est-value")[_value ?? "(not requested)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("storage-est-status")[_status ?? "(idle)"]]
];
private async Task Read()
{
try
{
if (!await Navigator.Storage.IsSupported)
{
_value = "not supported in this browser";
_status = "Storage estimate unavailable";
return;
}
// Both figures are coarse on purpose (anti-fingerprinting): a budget, not an exact count.
var e = await Navigator.Storage.Estimate();
var usage = e.Usage ?? 0;
var quota = e.Quota ?? 0;
var ratio = quota > 0 ? (double)usage / quota : 0;
_value = $"{Mb(usage)} / {Mb(quota)} MB used ({ratio:P1})";
_status = "Estimate read";
}
catch (JSException ex) { _status = "Read failed: " + ex.Message; }
}
private static string Mb(long bytes) => (bytes / 1024.0 / 1024.0).ToString("N1", CultureInfo.InvariantCulture);
}(not requested)(idle)Environment & capabilities
Navigator facts — await Navigator.OnLine, await Navigator.Language, await Navigator.UserAgent, from Rask.Web.
namespace Rask.Site.Features;
/// <summary>MDN's Navigator from Rask.Web — read-only navigator facts (online, language).</summary>
public sealed partial class NavigatorInfoDemo : Component
{
private string? _value;
private string? _status;
protected override Component? Render() =>
Ui.Card[
Ui.Button.Class("mb-2")
.Id("nav-read")
.OnClick(Read)["Read navigator info"],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Info: ", Code.Id("nav-value")[_value ?? "(not requested)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("nav-status")[_status ?? "(idle)"]]
];
private async Task Read()
{
try
{
var online = await Navigator.OnLine;
var language = await Navigator.Language;
_value = $"online: {online}, language: {language}";
_status = "Navigator read";
}
catch (Exception ex) { _status = "Read failed: " + ex.Message; }
}
}(not requested)(idle)Network quality — await Navigator.Connection.EffectiveType (and Downlink, Rtt, SaveData), from Rask.Web.
namespace Rask.Site.Features;
/// <summary>
/// MDN's <c>navigator.connection</c>, from Rask.Web — read the connection quality (effective type, downlink, Data
/// Saver). Chromium only.
/// </summary>
public sealed partial class NetworkInfoDemo : Component
{
private string? _value;
private string? _status;
protected override Component? Render() =>
Ui.Card[
Ui.Button.Class("mb-2")
.Id("net-read")
.OnClick(Read)["Read network status"],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Connection: ", Code.Id("net-value")[_value ?? "(not requested)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("net-status")[_status ?? "(idle)"]]
];
private async Task Read()
{
try
{
var connection = Navigator.Connection;
if (!await connection.IsSupported)
{
_value = "not supported (try a Chromium browser)";
_status = "Network Information unavailable";
return;
}
var type = (await connection.EffectiveType).ToString().TrimStart('_'); // MDN's "4g" is the enum's _4g
_value = $"{type}, {await connection.Downlink} Mbps, {await connection.Rtt} ms RTT, saveData: {await connection.SaveData}";
_status = "Network read";
}
catch (Exception ex) { _status = "Read failed: " + ex.Message; }
}
}(not requested)(idle)Battery — await using var battery = await Navigator.GetBattery(); then await battery.Level, from Rask.Web.
namespace Rask.Site.Features;
/// <summary>
/// MDN's <c>BatteryManager</c>, from Rask.Web — read the device charge level and charging state, and watch them
/// change. Chromium only: elsewhere <c>navigator.getBattery()</c> is missing and the call fails.
/// </summary>
public sealed partial class BatteryDemo : Component
{
private Types.BatteryManager? _battery;
private IAsyncDisposable? _levelWatch;
private IAsyncDisposable? _chargingWatch;
private double? _level;
private bool? _charging;
// Two labels, because the watch and the button write on their own schedules: one shared label let a change event
// landing after a click replace "read" with "live", which read as the button having done nothing.
private string _watchState = "(starting…)";
private string _readState = "(not read yet)";
protected override async Task OnFirstRender()
{
try
{
_battery ??= await Navigator.GetBattery();
_levelWatch = await _battery.OnLevelChange(Refresh);
_chargingWatch = await _battery.OnChargingChange(Refresh);
await Refresh();
_watchState = "live";
}
catch (Exception)
{
_watchState = "not supported on this browser";
}
}
protected override Component? Render() =>
Ui.Card[
Div.Class("flex gap-2 flex-wrap items-center mb-2")[
Ui.Button.Primary.Id("battery-read").OnClick(Read)["Read now"]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70 mb-1")[
"Level: ", Code.Id("battery-level")[_level is { } l ? $"{l * 100:0}%" : "(none)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70 mb-1")[
"Charging: ", Code.Id("battery-charging")[_charging switch { null => "(none)", true => "yes", _ => "no" }]],
Div.Class("text-sm text-zinc-500 dark:text-white/70 mb-1")[
"Watch: ", Code.Id("battery-watch")[_watchState]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("battery-status")[_readState]]
];
private async Task Refresh()
{
if (_battery is not null)
{
(_level, _charging) = (await _battery.Level, await _battery.Charging);
}
}
private async Task Read()
{
try
{
_battery ??= await Navigator.GetBattery();
await Refresh();
_readState = "read";
}
catch (Exception ex)
{
_readState = "failed: " + ex.Message;
}
}
protected override async Task OnUnmount()
{
if (_levelWatch is not null)
{
await _levelWatch.DisposeAsync();
}
if (_chargingWatch is not null)
{
await _chargingWatch.DisposeAsync();
}
if (_battery is not null)
{
await _battery.DisposeAsync();
}
}
}(none)(none)(starting…)(not read yet)Screen — await Screen.Width, await Screen.ColorDepth and await Window.DevicePixelRatio, from Rask.Web.
namespace Rask.Site.Features;
/// <summary>MDN's Screen and <c>devicePixelRatio</c> from Rask.Web — read the display size, color depth, and pixel ratio.</summary>
public sealed partial class ScreenInfoDemo : Component
{
private string? _value;
private string? _status;
protected override Component? Render() =>
Ui.Card[
Ui.Button.Class("mb-2")
.Id("screen-read")
.OnClick(Read)["Read screen info"],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Display: ", Code.Id("screen-value")[_value ?? "(not requested)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("screen-status")[_status ?? "(idle)"]]
];
private async Task Read()
{
try
{
var s = Window.Screen;
_value = $"{await s.Width}×{await s.Height} (avail {await s.AvailWidth}×{await s.AvailHeight}), "
+ $"{await s.ColorDepth}-bit, DPR {await Window.DevicePixelRatio}";
_status = "Screen read";
}
catch (Exception ex) { _status = "Read failed: " + ex.Message; }
}
}(not requested)(idle)Visual viewport — await Window.VisualViewport.Width (and Height, OffsetTop, Scale), from Rask.Web.
namespace Rask.Site.Features;
/// <summary>MDN's VisualViewport from Rask.Web — read the actually-visible viewport (size, offset, zoom).</summary>
public sealed partial class VisualViewportDemo : Component
{
private string? _value;
private string? _status;
protected override Component? Render() =>
Ui.Card[
Ui.Button.Class("mb-2")
.Id("vv-read")
.OnClick(Read)["Read visual viewport"],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Viewport: ", Code.Id("vv-value")[_value ?? "(not requested)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("vv-status")[_status ?? "(idle)"]]
];
private async Task Read()
{
try
{
var v = Window.VisualViewport;
if (!await v.IsSupported)
{
_value = "not supported in this browser";
_status = "Visual viewport unavailable";
return;
}
_value = $"{await v.Width:N0}×{await v.Height:N0} @ scale {await v.Scale:N2}, "
+ $"offset ({await v.OffsetLeft:N0}, {await v.OffsetTop:N0})";
_status = "Viewport read";
}
catch (Exception ex) { _status = "Read failed: " + ex.Message; }
}
}(not requested)(idle)Media queries — await Window.MatchMedia("(prefers-color-scheme: dark)").Matches, from Rask.Web.
namespace Rask.Site.Features;
/// <summary>MDN's <c>matchMedia</c> from Rask.Web — evaluate CSS media queries and user preferences from C#.</summary>
public sealed partial class MediaQueryDemo : Component
{
private string? _value;
private string? _status;
protected override Component? Render() =>
Ui.Card[
Ui.Button.Class("mb-2")
.Id("media-read")
.OnClick(Read)["Evaluate media queries"],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Result: ", Code.Id("media-value")[_value ?? "(not requested)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("media-status")[_status ?? "(idle)"]]
];
private async Task Read()
{
try
{
var wide = await Window.MatchMedia("(min-width: 768px)").Matches;
var dark = await Window.MatchMedia("(prefers-color-scheme: dark)").Matches;
var reduced = await Window.MatchMedia("(prefers-reduced-motion: reduce)").Matches;
_value = $"≥768px: {wide}, prefersDark: {dark}, reducedMotion: {reduced}";
_status = "Media queries evaluated";
}
catch (Exception ex) { _status = "Read failed: " + ex.Message; }
}
}(not requested)(idle)Page visibility — await Document.VisibilityState, from Rask.Web.
namespace Rask.Site.Features;
/// <summary>
/// MDN's <c>document.visibilityState</c>, from Rask.Web — read whether the page is foreground/visible, e.g. to pause
/// work when the user tabs away.
/// </summary>
public sealed partial class PageVisibilityDemo : Component
{
private string? _state;
private string? _status;
protected override Component? Render() =>
Ui.Card[
Ui.Button.Class("mb-2")
.Id("vis-read")
.OnClick(Read)["Read visibility"],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["State: ", Code.Id("vis-value")[_state ?? "(not read)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("vis-status")[_status ?? "(idle)"]]
];
private async Task Read()
{
try
{
_state = $"{await Document.VisibilityState} (hidden: {await Document.Hidden})";
_status = "Read";
}
catch (Exception ex) { _status = "Read failed: " + ex.Message; }
}
}(not read)(idle)IViewTransitions — animate between the old and new DOM instead of the new one just appearing.
The one wrapper here you could not have written yourself. A same-document transition has to wrap the
DOM mutation, and the mutation is the framework's morph — there is no point in your code that sits
around it. Enabling routes the live runtime's own commit (diff apply and full-document apply, on both
hosts) through document.startViewTransition.
Off by default, and off is exactly the previous behaviour: the commit stays synchronous. Style it
with the standard ::view-transition-* pseudo-elements; give an element a stable
view-transition-name and the browser morphs it between routes rather than cross-fading it, which is
what makes a shared header travel. prefers-reduced-motion is honoured for you — the animation is the
browser's own default, so there is no stylesheet of yours for the preference to switch off.
IsActive() is deliberately separate from what you set: a toggle can be on while nothing animates
because the browser lacks the API or the reader asked for less motion.
await _viewTransitions.SetEnabled(true);
Animations — await using var fade = await _box.Animate([new() { ["opacity"] = 0 }, new() { ["opacity"] = 1 }], 300)
on a typed element ref, from Rask.Web; await fade.Finished, await fade.Cancel(), and
await _box.GetAnimations() lists what runs. Reduced motion is yours to honour: read
await Window.MatchMedia("(prefers-reduced-motion: reduce)").Matches first.
Performance — await Performance.Now(), a high-resolution monotonic clock, from Rask.Web.
using System.Globalization;
namespace Rask.Site.Features;
/// <summary>MDN's <c>Performance</c>, from Rask.Web — the high-resolution clock and the page-load (navigation) entry.</summary>
public sealed partial class PerformanceDemo : Component
{
private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
private string? _value;
private string? _status;
protected override Component? Render() =>
Ui.Card[
Ui.Button.Class("mb-2").Id("perf-read").OnClick(Read)["Read performance timing"],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Timing: ", Code.Id("perf-value")[_value ?? "(not requested)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("perf-status")[_status ?? "(idle)"]]
];
private async Task Read()
{
try
{
var now = await Performance.Now();
var navigation = (await Performance.GetEntriesByType("navigation")).FirstOrDefault();
_value = navigation is null
? string.Create(Inv, $"now {now:F0} ms (no navigation entry)")
: string.Create(Inv, $"now {now:F0} ms, page load took {navigation.Duration:F0} ms");
_status = "Performance read";
}
catch (Exception ex) { _status = "Read failed: " + ex.Message; }
}
}(not requested)(idle)Permissions — await Navigator.Permissions.Query(new() { Name = "geolocation" }), from Rask.Web; read
State before triggering a prompt.
namespace Rask.Site.Features;
/// <summary>
/// MDN's <c>Permissions</c> from Rask.Web — query a feature's permission state (granted/denied/prompt) before
/// triggering it. Pairs with <c>Navigator.Geolocation</c> / <c>Navigator.Clipboard</c>.
/// </summary>
public sealed partial class PermissionsDemo : Component
{
private string? _geo;
private string? _clip;
private string? _status;
protected override Component? Render() =>
Ui.Card[
Div.Class("flex gap-2 flex-wrap items-center mb-2")[
Ui.Button
.Id("perm-geo")
.OnClick(QueryGeo)["Query geolocation"],
Ui.Button
.Id("perm-clip")
.OnClick(QueryClipboard)["Query clipboard-read"]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["geolocation: ", Code.Id("perm-geo-value")[_geo ?? "(unknown)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["clipboard-read: ", Code.Id("perm-clip-value")[_clip ?? "(unknown)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("perm-status")[_status ?? "(idle)"]]
];
private async Task QueryGeo()
{
try
{
_geo = await Query("geolocation");
_status = "Queried geolocation";
}
catch (Exception ex) { _status = "Query failed: " + ex.Message; }
}
private async Task QueryClipboard()
{
try
{
_clip = await Query("clipboard-read");
_status = "Queried clipboard-read";
}
catch (Exception ex) { _status = "Query failed: " + ex.Message; }
}
// navigator.permissions.query({ name }): the PermissionStatus is kept, so read its state and let it go.
private static async Task<string> Query(string name)
{
await using var status = await Navigator.Permissions.Query(new() { Name = name });
return (await status.State).ToString();
}
}(unknown)(unknown)(idle)Location, sensors & input
Geolocation — await Navigator.Geolocation.GetCurrentPosition(p => _where = p.Coords), from Rask.Web.
using System.Globalization;
namespace Rask.Site.Features;
/// <summary>MDN's Geolocation API from Rask.Web — one-shot current position, handed to a callback.</summary>
public sealed partial class GeolocationDemo : Component
{
private string? _location;
private string? _status;
protected override Component? Render() =>
Ui.Card[
Ui.Button.Class("mb-2")
.Id("geo-get")
.OnClick(Get)["Get current position"],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Position: ", Code.Id("geo-value")[_location ?? "(not requested)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("geo-status")[_status ?? "(idle)"]]
];
private async Task Get()
{
try
{
_status = "Locating…";
await Navigator.Geolocation.GetCurrentPosition(
p =>
{
_location = Describe(p.Coords);
_status = "Position acquired";
},
e => Failed(e.Message),
new Types.PositionOptions { Timeout = 10_000 });
}
catch (Exception ex) { Failed(ex.Message); }
}
private void Failed(string message)
{
_location = null;
_status = "Location failed: " + message;
}
// Coordinates format invariantly (decimal point) — independent of the server's locale.
internal static string Describe(Types.GeolocationCoordinates c) =>
string.Create(CultureInfo.InvariantCulture, $"lat {c.Latitude:F4}, lon {c.Longitude:F4} (±{c.Accuracy:F0} m)");
}(not requested)(idle)Live position — await Navigator.Geolocation.WatchPosition(p => …), from Rask.Web; each fix runs the handler.
namespace Rask.Site.Features;
/// <summary>
/// MDN's <c>watchPosition</c> from Rask.Web — live position tracking. The browser hands each fix to the
/// handler, which re-renders this; Stop calls <c>clearWatch</c>.
/// </summary>
public sealed partial class GeolocationWatchDemo : Component
{
private int? _watchId;
private string? _location;
private int _fixes;
private string? _status;
protected override Component? Render() =>
Ui.Card[
Div.Class("flex gap-2 flex-wrap items-center mb-2")[
_watchId is null
? Ui.Button.Primary.Id("geowatch-start").OnClick(Start)["Start watching"]
: Ui.Button.Red.Id("geowatch-stop").OnClick(Stop)["Stop"]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70")[
"Position: ", Code.Id("geowatch-value")[_location ?? "(not watching)"],
Span.Class("ms-2").Id("geowatch-fixes")[$"({_fixes} fix(es))"]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("geowatch-status")[_status ?? "(idle)"]]
];
private async Task Start()
{
try
{
_watchId = await Navigator.Geolocation.WatchPosition(
p =>
{
_fixes++;
_location = GeolocationDemo.Describe(p.Coords);
},
e => _status = "Watch failed: " + e.Message,
new Types.PositionOptions { EnableHighAccuracy = true });
_status = "Watching — move the device to see updates";
}
catch (Exception ex) { _status = "Watch failed: " + ex.Message; }
}
private async Task Stop()
{
await ClearWatch();
_status = "Stopped";
}
protected override Task OnUnmount() => ClearWatch();
private async Task ClearWatch()
{
if (_watchId is { } id)
{
_watchId = null;
await Navigator.Geolocation.ClearWatch(id);
}
}
}(not watching)(0 fix(es))(idle)Device orientation and motion — await Window.OnDeviceOrientation(e => _angle = e.Alpha, every: 100.Milliseconds) and
Window.OnDeviceMotion(e => …, every: …), from Rask.Web. every: throttles the
readings in the browser before they cross. On iOS, ask first with await DeviceOrientationEvent.RequestPermission()
(WASM, in the click).
using System.Globalization;
using Microsoft.JSInterop;
namespace Rask.Site.Features;
/// <summary>
/// MDN's <c>deviceorientation</c> and <c>devicemotion</c> events from Rask.Web — read the gyroscope/compass tilt
/// and the accelerometer. Tap <b>Start</b> (which asks for sensor permission inside the click, as iOS requires),
/// then tilt or shake the device: each reading re-renders the readout. Sensors only fire on a real device with
/// motion hardware.
/// </summary>
public sealed partial class DeviceSensorsDemo : Component
{
private IAsyncDisposable? _orientationWatch;
private IAsyncDisposable? _motionWatch;
private string _status = "(idle)";
private Types.DeviceOrientationEvent? _tilt;
private Types.DeviceMotionEventAcceleration? _accel;
private async Task Start()
{
try
{
if (!await DeviceOrientationEvent.IsSupported)
{
_status = "Device orientation not supported";
return;
}
// Ask for both before subscribing: iOS only honours requestPermission() while the click is still live.
var orientationAllowed = await Allowed(DeviceOrientationEvent.RequestPermission);
var motionAllowed = await Allowed(DeviceMotionEvent.RequestPermission);
if (!orientationAllowed)
{
_status = "Permission denied";
return;
}
// The sensors fire ~60 times a second; ten readings (the latest each time) is plenty for a readout.
_orientationWatch ??= await Window.OnDeviceOrientation(e => _tilt = e, every: 100.Milliseconds);
if (motionAllowed)
{
_motionWatch ??= await Window.OnDeviceMotion(e => _accel = e.Acceleration, every: 100.Milliseconds);
}
_status = "listening — tilt or shake the device";
}
catch (JSException ex)
{
_status = "start failed: " + ex.Message;
}
}
// Only iOS asks: elsewhere requestPermission() does not exist, and the sensors just fire.
private static async Task<bool> Allowed(Func<ValueTask<Types.PermissionState>> request)
{
try
{
return await request() is Types.PermissionState.Granted;
}
catch (JSException ex) when (ex.Message.Contains("is not a function", StringComparison.Ordinal))
{
return true;
}
}
protected override Component? Render() =>
Ui.Card[
Ui.Button.Primary.Class("mb-3").Id("sensor-start").OnClick(Start)["Start"],
Div.Class("text-sm text-zinc-500 dark:text-white/70 mb-2")["Status: ", Code.Id("sensor-status")[_status]],
Div.Class("grid grid-cols-12 gap-4")[
Div.Class("col-span-12 sm:col-span-6")[
Div.Class("font-semibold text-sm mb-1")["Orientation (°)"],
Div.Class("text-sm text-zinc-500 dark:text-white/70")[
"α ", Code.Id("sensor-alpha")[Fmt(_tilt?.Alpha)],
" · β ", Code.Id("sensor-beta")[Fmt(_tilt?.Beta)],
" · γ ", Code.Id("sensor-gamma")[Fmt(_tilt?.Gamma)]]
],
Div.Class("col-span-12 sm:col-span-6")[
Div.Class("font-semibold text-sm mb-1")["Acceleration (m/s²)"],
Div.Class("text-sm text-zinc-500 dark:text-white/70")[
"x ", Code.Id("sensor-ax")[Fmt(_accel?.X)],
" · y ", Code.Id("sensor-ay")[Fmt(_accel?.Y)],
" · z ", Code.Id("sensor-az")[Fmt(_accel?.Z)]]
]
]
];
private static string Fmt(double? value) => value is null ? "—" : value.Value.ToString("0.0", CultureInfo.InvariantCulture);
protected override async Task OnUnmount()
{
if (_orientationWatch is not null)
{
await _orientationWatch.DisposeAsync();
}
if (_motionWatch is not null)
{
await _motionWatch.DisposeAsync();
}
}
}(idle)— · β — · γ —— · y — · z —Gamepad — await Navigator.GetGamepads(), then each pad's Buttons and Axes, and
Window.OnGamepadConnected(e => …), from Rask.Web; prefer WASM for twitch input.
using System.Globalization;
using Microsoft.JSInterop;
namespace Rask.Site.Features;
/// <summary>
/// MDN's <c>navigator.getGamepads()</c> from Rask.Web — read connected game controllers and react to stick/button
/// input. MDN has no input event (a game polls every frame), so this demo reads the pads when one connects or
/// disconnects, and polls ten times a second while one is connected, re-rendering only when a reading changes.
/// </summary>
public sealed partial class GamepadDemo : Component
{
private static readonly TimeSpan Tick = TimeSpan.FromMilliseconds(100);
private List<Pad> _pads = [];
private IAsyncDisposable? _connected;
private IAsyncDisposable? _disconnected;
private Task _polling = Task.CompletedTask;
private bool _unmounted;
private string _status = "(idle)";
protected override async Task OnFirstRender()
{
if (_connected is not null)
{
return;
}
_connected = await Window.OnGamepadConnected(Changed);
_disconnected = await Window.OnGamepadDisconnected(Changed);
_status = "Ready — connect a controller and press a button";
await Changed();
}
protected override Component? Render() =>
Ui.Card[
Ui.Text.Class("mb-2")["Status: ", Code.Id("gamepad-status")[_status]],
Ui.Text.Class("mb-2")["Connected pads: ", Code.Id("gamepad-count")[_pads.Count]],
_pads.Count == 0
? Ui.Text["No controllers connected."]
: Ui.Table[
Ui.TableColumns[Ui.TableColumn["Pad"], Ui.TableColumn["Axes"], Ui.TableColumn["Buttons pressed"]],
Ui.TableRows[_pads.Select(p => Ui.TableRow.Key(p.Index)[
Ui.TableCell.Variant(Ui.TableCellVariant.Strong)[$"#{p.Index} — {p.Id}"],
Ui.TableCell[p.Axes],
Ui.TableCell[$"{p.Pressed}/{p.Buttons}"]
])]
]
];
private async Task Changed()
{
await Read();
if (_pads.Count > 0 && _polling.IsCompleted)
{
_polling = Poll();
}
}
// A pad only reports its state when asked, so ask every tick while one is connected.
private async Task Poll()
{
while (!_unmounted && _pads.Count > 0)
{
await Task.Delay(Tick);
if (await Read())
{
StateHasChanged();
}
}
}
// navigator.getGamepads(): one kept Gamepad per slot, null where none is connected. Read each, then let it go.
private async Task<bool> Read()
{
try
{
List<Pad> pads = [];
foreach (var gamepad in await Navigator.GetGamepads())
{
if (gamepad is null)
{
continue;
}
await using (gamepad)
{
var buttons = await gamepad.Buttons;
pads.Add(new Pad(
await gamepad.Index,
await gamepad.Id,
string.Join(", ", (await gamepad.Axes).Select(a => a.ToString("0.00", CultureInfo.InvariantCulture))),
buttons.Count(b => b.Pressed),
buttons.Length));
}
}
var changed = !pads.SequenceEqual(_pads);
_pads = pads;
return changed;
}
catch (JSException ex)
{
_status = "Gamepad API not supported: " + ex.Message;
_pads = [];
return true;
}
}
protected override async Task OnUnmount()
{
_unmounted = true;
if (_connected is not null)
{
await _connected.DisposeAsync();
}
if (_disconnected is not null)
{
await _disconnected.DisposeAsync();
}
}
private sealed record Pad(int Index, string Id, string Axes, int Pressed, int Buttons);
}Status: (idle)
Connected pads: 0
No controllers connected.
Vibration — await Navigator.Vibrate(200), from Rask.Web (mobile).
namespace Rask.Site.Features;
/// <summary>MDN's <c>navigator.vibrate</c>, from Rask.Web — pulse the device's vibration motor (effective on mobile).</summary>
public sealed partial class VibrationDemo : Component
{
private string? _status;
protected override Component? Render() =>
Ui.Card[
Div.Class("flex gap-2 flex-wrap items-center mb-2")[
Ui.Button
.Id("vibrate-buzz")
.OnClick(() => Vibrate([200], "Vibrated"))["Buzz"],
Ui.Button
.Id("vibrate-pattern")
.OnClick(() => Vibrate([100, 50, 100, 50, 300], "Pattern played"))["Pattern"],
Ui.Button.Red
.Id("vibrate-cancel")
.OnClick(() => Vibrate([0], "Cancelled"))["Cancel"]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("vibrate-status")[_status ?? "(idle)"]]
];
// A pattern of 0 cancels. A browser without navigator.vibrate (Safari, Firefox) fails the call outright.
private async Task Vibrate(int[] pattern, string done)
{
try
{
_status = await Navigator.Vibrate(pattern) ? done : "Not supported on this device";
}
catch (Exception) { _status = "Not supported on this device"; }
}
}(idle)Observers
MDN's own IntersectionObserver, ResizeObserver and MutationObserver, from Rask.Web:
await IntersectionObserver.Create(entries => …), then await observer.Observe(_ref). The handler gets the entries as
data and re-renders its component.
IntersectionObserver — notified when an element enters or leaves the viewport.
namespace Rask.Site.Features;
/// <summary>
/// MDN's <c>IntersectionObserver</c>, from Rask.Web — observe when an element enters or leaves the viewport. Scroll
/// the box below into view: the browser hands the entries to the C# handler, which re-renders this component.
/// </summary>
public sealed partial class IntersectionObserverDemo : Component
{
private readonly ElementRef _target = ElementRef.New();
private Types.IntersectionObserver? _observer;
private bool _visible;
private int _changes;
protected override async Task OnFirstRender()
{
_observer ??= await IntersectionObserver.Create(entries =>
{
_visible = entries[^1].IsIntersecting;
_changes++;
});
await _observer.Observe(_target);
}
protected override async Task OnUnmount()
{
if (_observer is not null)
{
await _observer.Disconnect();
await _observer.DisposeAsync();
}
}
protected override Component? Render() =>
Ui.Card[
Div.Class("flex gap-2 items-center flex-wrap mb-2")[
Ui.Badge
.Color(_visible ? Ui.Color.Green : null)
.Id("io-status")[_visible ? "in view" : "out of view"],
Span.Class("text-sm text-zinc-500 dark:text-white/70").Id("io-changes")[$"{_changes} change(s)"]
],
P.Class("text-sm text-zinc-500 dark:text-white/70 mb-2")["Scroll down — the target reports when it enters the viewport."],
// A tall spacer so the target starts below the fold, then the observed target.
Div.Style("height: 130vh"),
Div
.Ref(_target)
.Id("io-target")
.Class("p-4 rounded text-center " + (_visible ? "bg-success-subtle" : "bg-zinc-50 dark:bg-zinc-900"))[
"🎯 observed target"
]
];
}Scroll down — the target reports when it enters the viewport.
ResizeObserver — notified when an element's size changes.
using System.Globalization;
namespace Rask.Site.Features;
/// <summary>
/// MDN's <c>ResizeObserver</c>, from Rask.Web — report an element's size as it changes. The box below is observed;
/// toggle its width (or resize the window) and the browser hands the new size to the C# handler.
/// </summary>
public sealed partial class ResizeObserverDemo : Component
{
private static readonly CultureInfo Inv = CultureInfo.InvariantCulture;
private readonly ElementRef _box = ElementRef.New();
private Types.ResizeObserver? _observer;
private double _width;
private double _height;
private bool _wide = true;
protected override async Task OnFirstRender()
{
_observer ??= await ResizeObserver.Create(entries =>
{
var size = entries[^1].ContentRect;
(_width, _height) = (size.Width, size.Height);
});
await _observer.Observe(_box);
}
protected override async Task OnUnmount()
{
if (_observer is not null)
{
await _observer.Disconnect();
await _observer.DisposeAsync();
}
}
protected override Component? Render() =>
Ui.Card[
Div.Class("text-sm text-zinc-500 dark:text-white/70 mb-2")[
"Observed size: ",
Code.Id("resize-value")[
_width > 0 ? $"{_width.ToString("0", Inv)} × {_height.ToString("0", Inv)} px" : "(measuring…)"]
],
Ui.Button.Class("mb-2")
.Id("resize-toggle")
.OnClick(() => _wide = !_wide)["Toggle width"],
Div
.Ref(_box)
.Id("resize-box")
.Class("p-4 rounded bg-zinc-50 dark:bg-zinc-900 text-center", _wide ? "w-full" : "w-1/2")[
"📐 observed box (resize the window too)"
]
];
}(measuring…)MutationObserver — notified when an element's children or attributes change.
using System.Globalization;
namespace Rask.Site.Features;
/// <summary>
/// MDN's <c>MutationObserver</c>, from Rask.Web — observe DOM changes (children, attributes) on an element. Mutate
/// the watched box with the buttons: the browser hands each batch of <c>MutationRecord</c>s to the C# handler.
/// </summary>
public sealed partial class MutationObserverDemo : Component
{
private readonly ElementRef _target = ElementRef.New();
private Types.MutationObserver? _observer;
private int _items = 1;
private bool _highlight;
private int _childChanges;
private int _attrChanges;
private string _last = "(none yet)";
protected override async Task OnFirstRender()
{
_observer ??= await MutationObserver.Create(records =>
{
foreach (var record in records)
{
if (record.Type is "attributes")
{
_attrChanges++;
_last = $"attributes ({record.AttributeName})";
}
else
{
_childChanges++;
_last = record.Type;
}
}
});
await _observer.Observe(_target, new() { ChildList = true, Attributes = true, Subtree = true });
}
protected override async Task OnUnmount()
{
if (_observer is not null)
{
await _observer.Disconnect();
await _observer.DisposeAsync();
}
}
protected override Component? Render() =>
Ui.Card[
Div.Class("flex gap-2 flex-wrap items-center mb-3")[
Ui.Button.Primary.Id("mo-add").OnClick(() => _items++)["Add item"],
Ui.Button
.Id("mo-remove")
.OnClick(() => { if (_items > 0) _items--; })["Remove item"],
Ui.Button
.Id("mo-toggle")
.OnClick(() => _highlight = !_highlight)["Toggle attribute"]
],
Div
.Ref(_target)
.Id("mo-target")
.Class("border rounded p-3 mb-3" + (_highlight ? " border-warning bg-warning-subtle" : ""))[
Ul.Class("mb-0")[
Enumerable.Range(1, _items).Select(i => Li.Key(i.ToString(CultureInfo.InvariantCulture))[$"item {i}"])
]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70")[
"childList changes: ", Code.Id("mo-child")[$"{_childChanges}"],
" · attribute changes: ", Code.Id("mo-attr")[$"{_attrChanges}"]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Last: ", Code.Id("mo-last")[_last]]
];
}- item 1
0 · attribute changes: 0(none yet)Media, crypto & files
Clipboard — await Navigator.Clipboard.WriteText("hi") and await Navigator.Clipboard.ReadText(), from Rask.Web.
namespace Rask.Site.Features;
/// <summary>MDN's Clipboard API from Rask.Web — copy to and read back from the system clipboard.</summary>
public sealed partial class ClipboardDemo : Component
{
private string _input = "Copied from Rask!";
private string? _read;
private string? _status;
protected override Component? Render() =>
Ui.Card[
Div.Class("mb-2 flex gap-2")[
Ui.Input.Value(_input).Label("Text to copy").Id("clipboard-input").OnInput(v => _input = v),
Ui.Button.Primary.Id("clipboard-copy").OnClick(Copy)["Copy"],
Ui.Button.Id("clipboard-paste").OnClick(Paste)["Paste"]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Pasted: ", Code.Id("clipboard-read-value")[_read ?? "(nothing yet)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("clipboard-status")[_status ?? "(idle)"]]
];
private async Task Copy()
{
try
{
await Navigator.Clipboard.WriteText(_input);
_status = "Copied to clipboard";
}
catch (Exception ex) { _status = "Copy failed: " + ex.Message; }
}
private async Task Paste()
{
try
{
_read = await Navigator.Clipboard.ReadText();
_status = "Pasted from clipboard";
}
catch (Exception ex) { _status = "Paste failed: " + ex.Message; }
}
}(nothing yet)(idle)Speech synthesis — await using var u = await SpeechSynthesisUtterance.Create("Hello"), then
await SpeechSynthesis.Speak(u) and await SpeechSynthesis.Cancel(), from Rask.Web.
using Microsoft.JSInterop;
namespace Rask.Site.Features;
/// <summary>MDN's <c>SpeechSynthesis</c> from Rask.Web — speak text aloud (text-to-speech) in the browser's own voices.</summary>
public sealed partial class SpeechDemo : Component
{
private string _text = "Hello from Rask — spoken straight from C#.";
private string? _status;
protected override Component? Render() =>
Ui.Card[
Ui.Input
.Value(_text)
.Label("Text to speak")
.Id("speech-text")
.Class("mb-2")
.OnInput(v => _text = v),
Div.Class("flex gap-2 flex-wrap items-center mb-2")[
Ui.Button.Primary.Id("speech-speak").OnClick(Speak)["Speak"],
Ui.Button.Red
.Id("speech-cancel")
.OnClick(Cancel)["Stop"]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("speech-status")[_status ?? "(idle)"]]
];
private async Task Speak()
{
try
{
if (!await SpeechSynthesis.IsSupported)
{
_status = "Speech synthesis not supported in this browser";
return;
}
await using var utterance = await SpeechSynthesisUtterance.Create(_text);
await utterance.SetLang("en-US");
await SpeechSynthesis.Speak(utterance);
_status = "Speaking";
}
catch (JSException ex)
{
_status = "Failed: " + ex.Message;
}
}
private async Task Cancel()
{
try
{
await SpeechSynthesis.Cancel();
_status = "Stopped";
}
catch (JSException ex)
{
_status = "Failed: " + ex.Message;
}
}
}(idle)Speech recognition — dictation: _rec = await SpeechRecognition.Create(), await _rec.OnResult(e => …) reading
e.Results[i][0].Transcript and e.Results[i].IsFinal, then await _rec.Start() and await _rec.Stop(), from
Rask.Web (which falls back to webkitSpeechRecognition where the browser needs it).
using Microsoft.JSInterop;
namespace Rask.Site.Features;
/// <summary>
/// MDN's <c>SpeechRecognition</c> from Rask.Web — dictation: start listening, and each <c>result</c> event hands the
/// handler the results as data (final phrases accumulate; the interim hypothesis shows live). Prompts for
/// microphone access on start. Chromium and Safari still ship it as <c>webkitSpeechRecognition</c>, which Rask.Web
/// finds under its MDN name. The handlers are lambdas in this component, so they re-render it.
/// </summary>
public sealed partial class SpeechRecognitionDemo : Component
{
private Types.SpeechRecognition? _recognition;
private readonly List<IAsyncDisposable> _subscriptions = [];
private string _transcript = "";
private string _interim = "";
private string _status = "(idle)";
private bool Listening => _recognition is not null;
protected override Component? Render() =>
Ui.Card[
Div.Class("flex gap-2 flex-wrap items-center mb-2")[
Ui.Button.Primary
.Id("speech-recognize-start")
.Disabled(Listening)
.OnClick(Start)["Start listening"],
Ui.Button.Red
.Id("speech-recognize-stop")
.Disabled(!Listening)
.OnClick(Stop)["Stop"]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70 mb-1")[
"Transcript: ",
Code.Id("speech-recognize-transcript")[_transcript.Length == 0 ? "(none)" : _transcript],
_interim.Length == 0 ? (Component?)null : Span.Class("text-zinc-500 dark:text-white/70 italic")[" ", _interim]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("speech-recognize-status")[_status]]
];
private async Task Start()
{
if (!await SpeechRecognition.IsSupported)
{
_status = "not supported on this browser";
return;
}
_transcript = "";
_interim = "";
try
{
_recognition = await SpeechRecognition.Create();
await _recognition.SetContinuous(true);
await _recognition.SetInterimResults(true);
_subscriptions.Add(await _recognition.OnResult(e => Heard(e)));
_subscriptions.Add(await _recognition.OnError(e => _status = "error: " + e.Error));
_subscriptions.Add(await _recognition.OnEnd(async () => await Stop()));
await _recognition.Start();
_status = "listening…";
}
catch (JSException ex)
{
_status = "failed: " + ex.Message;
await Release();
}
}
// The results from ResultIndex on are new: a final one joins the transcript, an interim one shows as it is heard.
private void Heard(Types.SpeechRecognitionEvent e)
{
var heard = e.Results.Items.Skip(e.ResultIndex).ToList();
foreach (var phrase in heard.Where(r => r.IsFinal))
{
_transcript = (_transcript + " " + phrase[0].Transcript).Trim();
}
_interim = string.Concat(heard.Where(r => !r.IsFinal).Select(r => r[0].Transcript));
}
private async Task Stop()
{
if (_recognition is not null)
{
await _recognition.Stop();
await Release();
_status = "stopped";
}
_interim = "";
}
private async Task Release()
{
foreach (var subscription in _subscriptions)
{
await subscription.DisposeAsync();
}
_subscriptions.Clear();
if (_recognition is not null)
{
await _recognition.DisposeAsync();
_recognition = null;
}
}
protected override Task OnUnmount() => Stop();
}(none)(idle)Media session — await Navigator.MediaSession.SetMetadata(await MediaMetadata.Create(new() { Title = "…" })) and
SetActionHandler(action, details => …), from Rask.Web; now-playing metadata and hardware media keys.
namespace Rask.Site.Features;
/// <summary>
/// MDN's <c>MediaSession</c> from Rask.Web — publish now-playing metadata to the OS (lock screen / media hub) and
/// handle hardware media keys. Publish the metadata, then press a media key (or use the lock-screen controls): the
/// browser runs the C# action handler, which shows the action below. Honored fully only while media is actually
/// playing.
/// </summary>
public sealed partial class MediaSessionDemo : Component
{
private static readonly Types.MediaSessionAction[] Actions =
[
Types.MediaSessionAction.Play, Types.MediaSessionAction.Pause,
Types.MediaSessionAction.Previoustrack, Types.MediaSessionAction.Nexttrack
];
private readonly List<Types.MediaSessionAction> _handled = [];
private string _status = "(idle)";
private string _last = "(none yet)";
protected override async Task OnFirstRender()
{
if (!await Navigator.MediaSession.IsSupported)
{
_status = "Media Session not supported";
return;
}
foreach (var action in Actions.Except(_handled))
{
try
{
await Navigator.MediaSession.SetActionHandler(action, details => _last = details.Action.ToString());
_handled.Add(action);
}
catch (Microsoft.JSInterop.JSException)
{
// The browser does not support this particular action: setActionHandler throws, so skip it.
}
}
}
protected override Component? Render() =>
Ui.Card[
Div.Class("flex gap-2 flex-wrap items-center mb-3")[
Ui.Button.Primary.Id("ms-publish").OnClick(Publish)["Publish metadata"],
Ui.Button
.Id("ms-playing")
.OnClick(() => SetState(Types.MediaSessionPlaybackState.Playing, "playing"))["Mark playing"],
Ui.Button
.Id("ms-paused")
.OnClick(() => SetState(Types.MediaSessionPlaybackState.Paused, "paused"))["Mark paused"],
Ui.Button.Red.Id("ms-clear").OnClick(Clear)["Clear"]
],
P.Class("text-sm text-zinc-500 dark:text-white/70 mb-2")[
"After publishing, use your keyboard's media keys (or the OS media controls) — the action "
+ "shows below. Lock-screen integration activates fully while audio is playing."],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("ms-status")[_status]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Last action: ", Code.Id("ms-last")[_last]]
];
// navigator.mediaSession.metadata = new MediaMetadata({ … }): the new object is kept, set by its handle, then let go.
private async Task Publish()
{
try
{
await using var metadata = await Rask.Web.MediaMetadata.Create(new()
{
Title = "Rask Showcase Track",
Artist = "Rask",
Album = "Browser APIs",
Artwork = [new() { Src = "icon.svg", Sizes = "any", Type = "image/svg+xml" }]
});
await Navigator.MediaSession.SetMetadata(metadata);
_status = "metadata published";
}
catch (Exception ex)
{
_status = "publish failed: " + ex.Message;
}
}
private async Task SetState(Types.MediaSessionPlaybackState state, string label)
{
try
{
await Navigator.MediaSession.SetPlaybackState(state);
_status = $"playback state: {label}";
}
catch (Exception ex)
{
_status = "set state failed: " + ex.Message;
}
}
private async Task Clear()
{
try
{
await Navigator.MediaSession.SetMetadata(null);
await Navigator.MediaSession.SetPlaybackState(Types.MediaSessionPlaybackState.None);
_status = "cleared";
_last = "(none yet)";
}
catch (Exception ex)
{
_status = "clear failed: " + ex.Message;
}
}
// setActionHandler(action, null) takes each handler back off the OS controls.
protected override async Task OnUnmount()
{
foreach (var action in _handled)
{
await Navigator.MediaSession.SetActionHandler(action, (Action<Types.MediaSessionActionDetails>?)null);
}
}
}After publishing, use your keyboard's media keys (or the OS media controls) — the action shows below. Lock-screen integration activates fully while audio is playing.
(idle)(none yet)Crypto — await Crypto.RandomUUID(), await Crypto.GetRandomValues(new byte[16]) and
await Crypto.Subtle.Digest("SHA-256", bytes), from Rask.Web.
using System.Text;
namespace Rask.Site.Features;
/// <summary>MDN's <c>Crypto</c> from Rask.Web — native randomness (UUID, bytes) and hashing (SHA-256) from C#.</summary>
public sealed partial class CryptoDemo : Component
{
private string _text = "hello";
private string? _uuid;
private string? _hash;
private string? _bytes;
private string? _status;
protected override Component? Render() =>
Ui.Card[
Div.Class("flex gap-2 flex-wrap items-center mb-2")[
Ui.Button.Id("crypto-uuid").OnClick(Uuid)["Random UUID"],
Ui.Button.Id("crypto-bytes").OnClick(Bytes)["Random bytes"]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["UUID: ", Code.Id("crypto-uuid-value")[_uuid ?? "(none)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70 mb-2")["Bytes: ", Code.Id("crypto-bytes-value")[_bytes ?? "(none)"]],
Ui.Input
.Value(_text)
.Label("Text to hash")
.Id("crypto-text")
.Class("mb-2")
.OnInput(v => _text = v),
Ui.Button.Primary.Class("mb-2").Id("crypto-hash").OnClick(Hash)["SHA-256"],
Div.Class("text-sm text-zinc-500 dark:text-white/70 text-break")["Hash: ", Code.Id("crypto-hash-value")[_hash ?? "(none)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("crypto-status")[_status ?? "(idle)"]]
];
// crypto.randomUUID()
private async Task Uuid()
{
try { _uuid = await Crypto.RandomUUID(); _status = "UUID generated"; }
catch (Exception ex) { _status = "Failed: " + ex.Message; }
}
// crypto.getRandomValues(new Uint8Array(8)): the browser fills the array and answers with it.
private async Task Bytes()
{
try
{
_bytes = Convert.ToHexStringLower(await Crypto.GetRandomValues(new byte[8]));
_status = "Bytes generated";
}
catch (Exception ex) { _status = "Failed: " + ex.Message; }
}
// crypto.subtle.digest("SHA-256", bytes): the hash comes back as a byte[].
private async Task Hash()
{
try
{
_hash = Convert.ToHexStringLower(await Crypto.Subtle.Digest("SHA-256", Encoding.UTF8.GetBytes(_text)));
_status = "Hashed";
}
catch (Exception ex) { _status = "Failed: " + ex.Message; }
}
}(none)(none)(none)(idle)File System Access — await Window.ShowOpenFilePicker(…) (WASM), then await (await files[0].GetFile()).Text();
ShowSaveFilePicker and CreateWritable() save it back, from Rask.Web (Chromium-family).
using Microsoft.JSInterop;
namespace Rask.Site.Features;
/// <summary>
/// MDN's File System Access API from Rask.Web — a tiny text editor: open a file from disk, edit it, and save it
/// <em>back to the same file</em> (or "Save as…" to a new one). Falls back to a notice where the pickers don't
/// exist (Firefox/Safari).
/// </summary>
public sealed partial class FileSystemAccessDemo : Component
{
private Types.FileSystemFileHandle? _handle;
private string? _name;
private string _text = string.Empty;
private string _status = "(idle)";
protected override Component? Render() =>
Ui.Card[
Div.Class("flex gap-2 flex-wrap items-center mb-2")[
Ui.Button.Primary.Icon(Ui.IconName.FolderOpen).Id("fs-open").OnClick(Open)["Open file"],
Ui.Button.Icon(Ui.IconName.ArrowDownOnSquare)
.Id("fs-save")
.Disabled(_handle is null)
.OnClick(Save)["Save"],
Ui.Button.Id("fs-saveas").OnClick(SaveAs)["Save as…"]
],
Div.Class("mb-2 text-sm text-zinc-500 dark:text-white/70")["File: ", Code.Id("fs-name")[_name ?? "(none)"]],
Ui.Textarea
.Value(_text)
.Label("File contents")
.Id("fs-text")
.Class("mb-2")
.Rows(8)
.Description("Open a text file, or type here and Save as…")
.OnInput(v => _text = v),
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("fs-status")[_status]]
];
private const string Unsupported = "File System Access not supported — use Chrome/Edge";
// window.showOpenFilePicker(): every file picked comes back kept; dismissing the picker rejects with AbortError.
private async Task Open()
{
try
{
var picked = await Window.ShowOpenFilePicker(new()
{
Types =
[
new()
{
Description = "Text files",
Accept = new(StringComparer.Ordinal) { ["text/plain"] = [".txt", ".md", ".json", ".cs"] }
}
]
});
await Adopt(picked[0]);
await using var file = await picked[0].GetFile();
_text = await file.Text();
_status = $"Opened {_name} ({_text.Length} chars)";
}
catch (JSException ex) when (IsMissing(ex))
{
_status = Unsupported;
}
catch (JSException ex)
{
_status = "Open failed: " + ex.Message;
}
}
private async Task Save()
{
if (_handle is null)
{
return;
}
try
{
await WriteText(_handle);
_status = $"Saved {_name}";
}
catch (JSException ex)
{
_status = "Save failed: " + ex.Message;
}
}
private async Task SaveAs()
{
try
{
var handle = await Window.ShowSaveFilePicker(new() { SuggestedName = "rask-note.txt" });
await Adopt(handle);
await WriteText(handle);
_status = $"Saved to {_name}";
}
catch (JSException ex) when (IsMissing(ex))
{
_status = Unsupported;
}
catch (JSException ex)
{
_status = "Save failed: " + ex.Message;
}
}
// A browser without the pickers has no window.showOpenFilePicker / showSaveFilePicker to call.
private static bool IsMissing(JSException ex) => ex.Message.Contains("is not a function", StringComparison.Ordinal);
private async Task WriteText(Types.FileSystemFileHandle handle)
{
await using var writable = await handle.CreateWritable();
await writable.Write(_text);
await writable.Close();
}
// Let the previous handle go before keeping a new one, so handles don't pile up across opens.
private async Task Adopt(Types.FileSystemFileHandle handle)
{
if (_handle is not null)
{
await _handle.DisposeAsync();
}
_handle = handle;
_name = await handle.Name;
}
protected override async Task OnUnmount()
{
if (_handle is not null)
{
await _handle.DisposeAsync();
}
}
}(none)(idle)Origin private file system — await Navigator.Storage.GetDirectory(), then
GetFileHandle(name, new() { Create = true }), from Rask.Web; a private, persistent file tree the
app owns, with no picker. The right home for a local database file.
using System.Text;
using Microsoft.JSInterop;
namespace Rask.Site.Features;
/// <summary>
/// MDN's origin private file system from Rask.Web — write a byte range into an app-owned file, read it back,
/// and ask for the origin's storage to survive eviction.
/// </summary>
public sealed partial class OriginPrivateFileSystemDemo : Component
{
private const string Folder = "demo";
private const string FileName = "notes.bin";
private const long Offset = 4096;
private string? _content;
private string? _size;
private string? _status;
protected override Component? Render() =>
Ui.Card[
Div.Class("flex flex-wrap gap-2 mb-2")[
Ui.Button
.Id("opfs-write")
.OnClick(Write)["Write at 4096"],
Ui.Button
.Id("opfs-read")
.OnClick(Read)["Read back"],
Ui.Button
.Id("opfs-persist")
.OnClick(Persist)["Request persistence"]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Content: ", Code.Id("opfs-content")[_content ?? "(not read)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["File size: ", Code.Id("opfs-size")[_size ?? "(unknown)"]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("opfs-status")[_status ?? "(idle)"]]
];
// Writing at an offset leaves everything outside the range intact and zero-fills the gap up to it, so
// the file ends up larger than the bytes written — that's the point of a ranged write.
private async Task Write()
{
if (!await Supported())
{
return;
}
try
{
await using var handle = await OpenFile(create: true);
await using (var writable = await handle.CreateWritable(new() { KeepExistingData = true }))
{
await writable.Seek(Offset);
await writable.Write(Encoding.UTF8.GetBytes("hello from OPFS"));
await writable.Close();
}
await using var file = await handle.GetFile();
_size = $"{await file.Size} bytes";
_status = "Wrote 15 bytes at offset 4096";
}
catch (JSException ex) { _status = "Write failed: " + ex.Message; }
}
private async Task Read()
{
if (!await Supported())
{
return;
}
try
{
await using var handle = await OpenFile(create: false);
await using var file = await handle.GetFile();
_content = Encoding.UTF8.GetString(await file.Slice(Offset, Offset + 15).ArrayBuffer());
_status = "Read 15 bytes at offset 4096";
}
catch (JSException ex)
{
_content = "(file does not exist)";
_status = "Read failed: " + ex.Message;
}
}
// navigator.storage.getDirectory() is the origin's root; without `create` a missing entry rejects (NotFoundError).
private static async Task<Types.FileSystemFileHandle> OpenFile(bool create)
{
Types.FileSystemFileHandle file;
await using (var root = await Navigator.Storage.GetDirectory())
await using (var folder = await root.GetDirectoryHandle(Folder, new() { Create = create }))
{
file = await folder.GetFileHandle(FileName, new() { Create = create });
}
return file;
}
private async Task<bool> Supported()
{
if (await Navigator.Storage.IsSupported)
{
return true;
}
_status = "OPFS unavailable in this browser";
return false;
}
// OPFS is persistent but still evictable under storage pressure until the origin is exempted.
private async Task Persist()
{
try
{
var persisted = await Navigator.Storage.Persisted() || await Navigator.Storage.Persist();
_status = persisted
? "Storage is exempt from eviction"
: "Storage is still evictable (declined or unsupported)";
}
catch (JSException ex) { _status = "Persist request failed: " + ex.Message; }
}
}(not read)(unknown)(idle)IWebAuthn — register and sign in with a passkey instead of a password.
using System.Buffers.Text;
using System.Security.Cryptography;
using Rask.Core.Browser;
namespace Rask.Site.Features;
/// <summary>
/// <see cref="IWebAuthn" /> — register a passkey, then sign in with it. The challenge is normally issued
/// and verified by your <em>backend</em>; this demo generates it client-side and just displays the
/// returned attestation/assertion (no server verification), to show the browser round-trip.
/// </summary>
public sealed partial class WebAuthnDemo(IWebAuthn webAuthn) : Component
{
// A stable user handle for this demo session (a real app uses the account's server-side id).
private readonly string _userId = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(16));
private string? _credentialId;
private string _status = "(idle)";
private string _support = "(unchecked)";
protected override async Task OnFirstRender()
{
try
{
if (!await webAuthn.IsSupported())
{
_support = "WebAuthn not supported in this browser";
}
else
{
var platform = await webAuthn.IsPlatformAuthenticatorAvailable();
_support = platform
? "Supported — platform authenticator available"
: "Supported — security key only";
}
}
catch (Exception ex)
{
_support = "Support check failed: " + ex.Message;
}
StateHasChanged();
}
protected override Component? Render() =>
Ui.Card[
Div.Class("flex gap-2 flex-wrap items-center mb-2")[
Ui.Button.Primary.Icon(Ui.IconName.FingerPrint).Id("webauthn-create").OnClick(Create)["Create passkey"],
Ui.Button
.Id("webauthn-auth")
.Disabled(_credentialId is null)
.OnClick(Authenticate)["Authenticate"]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Support: ", Code.Id("webauthn-support")[_support]],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("webauthn-status")[_status]]
];
private async Task Create()
{
try
{
var result = await webAuthn.Create(new PublicKeyCredentialCreationOptions
{
Challenge = NewChallenge(),
Rp = new RelyingParty("Rask Showcase"),
User = new PublicKeyCredentialUser(_userId, "demo@rask.dev", "Rask Demo"),
AuthenticatorSelection = new AuthenticatorSelection { UserVerification = "preferred" }
});
if (result is null)
{
_status = "Registration cancelled";
return;
}
_credentialId = result.Id;
_status = $"Passkey created (credential {Shorten(result.Id)}) — now Authenticate";
}
catch (Exception ex)
{
_status = "Registration failed: " + ex.Message;
}
}
private async Task Authenticate()
{
try
{
var result = await webAuthn.Get(new PublicKeyCredentialRequestOptions
{
Challenge = NewChallenge(),
UserVerification = "preferred",
AllowCredentials = _credentialId is null
? null
: [new CredentialDescriptor(_credentialId)]
});
_status = result is null
? "Authentication cancelled"
: $"Signed in — assertion received (signature {Shorten(result.Signature)}). A real app verifies it server-side.";
}
catch (Exception ex)
{
_status = "Authentication failed: " + ex.Message;
}
}
private static string NewChallenge() => Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
private static string Shorten(string b64) => b64.Length <= 12 ? b64 : b64[..12] + "…";
}(unchecked)(idle)BroadcastChannel — await BroadcastChannel.Create("cart"), then PostMessage(msg) and OnMessage(e => …), from
Rask.Web; messages between same-origin tabs (open this guide in a second tab to try it).
namespace Rask.Site.Features;
/// <summary>
/// MDN's <c>BroadcastChannel</c> from Rask.Web — same-origin messaging between browsing contexts. This demo opens
/// two connections to one channel in the same page: posting on the sender is delivered to the receiver (a
/// connection never receives its own posts). Open this page in a second tab to see cross-tab delivery. The
/// receiver's <c>message</c> handler is a lambda in this component, so it re-renders it.
/// </summary>
public sealed partial class BroadcastChannelDemo : Component
{
private const string ChannelName = "rask-broadcast-demo";
private Types.BroadcastChannel? _sender;
private Types.BroadcastChannel? _receiver;
private IAsyncDisposable? _listening;
private readonly List<string> _received = [];
private int _counter;
protected override async Task OnFirstRender()
{
if (_receiver is not null)
{
return;
}
_sender = await BroadcastChannel.Create(ChannelName);
_receiver = await BroadcastChannel.Create(ChannelName);
_listening = await _receiver.OnMessage(e => _received.Insert(0, e.Data<string>()));
}
protected override Component? Render() =>
Ui.Card[
Ui.Button.Primary.Class("mb-2").Id("bc-send").OnClick(Send)["Broadcast a message"],
Div.Class("text-sm text-zinc-500 dark:text-white/70 mb-1")["Received (from other connections/tabs):"],
_received.Count == 0
? Div.Class("text-sm text-zinc-500 dark:text-white/70 italic").Id("bc-log")["(none yet)"]
: Ul.Class("text-sm mb-0").Id("bc-log")[
_received.Select(m => Li.Key(m)[m])
]
];
private async Task Send()
{
if (_sender is not null)
{
await _sender.PostMessage($"Message #{++_counter}");
}
}
protected override async Task OnUnmount()
{
if (_listening is not null)
{
await _listening.DisposeAsync();
}
await Close(_sender);
await Close(_receiver);
}
// close() stops the channel delivering; disposing lets the browser drop the kept object.
private static async Task Close(Types.BroadcastChannel? channel)
{
if (channel is not null)
{
await channel.Close();
await channel.DisposeAsync();
}
}
}Web Locks — await Navigator.Locks.Request(name, async lk => { … }) holds the named lock until the handler
returns; new() { IfAvailable = true } hands the handler null at once instead of waiting when it is held, and
Query() snapshots the held and waiting locks — from Rask.Web. Open this guide in a second tab and
click "Hold" in both to watch one wait for the other.
using Microsoft.JSInterop;
namespace Rask.Site.Features;
/// <summary>
/// MDN's Web Locks API from Rask.Web — coordinate work across the tabs and workers of one origin. This demo holds
/// an exclusive lock for two seconds: open this page in a second tab and click "Hold" in both — the second waits
/// for the first to release. "Try (no wait)" asks with <c>ifAvailable</c>, so while the lock is held it is handed
/// no lock at once instead of waiting. "Query" snapshots the locks the origin holds and waits for now.
/// </summary>
public sealed partial class WebLocksDemo : Component
{
private const string LockName = "rask-web-locks-demo";
private string _status = "(idle)";
private (Types.LockInfo Lock, string State)[] _snapshot = [];
protected override Component? Render() =>
Ui.Card[
Div.Class("flex gap-2 flex-wrap items-center mb-2")[
Ui.Button.Primary.Id("locks-hold").OnClick(Hold)["Hold exclusive for 2s"],
Ui.Button
.Id("locks-try")
.OnClick(TryHold)["Try (no wait)"],
Ui.Button
.Id("locks-query")
.OnClick(Query)["Query held locks"]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70 mb-1")["Status: ", Code.Id("locks-status")[_status]],
_snapshot.Length == 0
? Div.Class("text-sm text-zinc-500 dark:text-white/70 italic").Id("locks-snapshot")["(query to see held locks)"]
: Ul.Class("text-sm mb-0").Id("locks-snapshot")[
_snapshot.Select((l, i) => Li.Key($"{i}:{l.Lock.Name}")[$"{l.Lock.Name} — {l.Lock.Mode} — {l.State}"])
]
];
private async Task Hold()
{
if (!await Navigator.Locks.IsSupported)
{
_status = "not supported";
return;
}
try
{
// The lock is held until this handler returns, so other tabs wait for the two seconds.
await Navigator.Locks.Request(LockName, async _ =>
{
_status = "holding — other tabs wait here";
StateHasChanged(); // shown while the handler still runs, not once it has returned
await Task.Delay(2000);
});
_status = "released";
}
catch (JSException ex)
{
_status = "failed: " + ex.Message;
}
}
private async Task TryHold()
{
try
{
var got = false;
await Navigator.Locks.Request(LockName, new() { IfAvailable = true }, granted => got = granted is not null);
_status = got ? "try: acquired (and released)" : "try: already held — stood down";
}
catch (JSException ex)
{
_status = "failed: " + ex.Message;
}
}
private async Task Query()
{
try
{
var locks = await Navigator.Locks.Query();
_snapshot = [.. (locks.Held ?? []).Select(l => (l, "held")), .. (locks.Pending ?? []).Select(l => (l, "pending"))];
_status = $"queried: {locks.Held?.Length ?? 0} held, {locks.Pending?.Length ?? 0} waiting";
}
catch (JSException ex)
{
_status = "failed: " + ex.Message;
}
}
}(idle)IWebRtc — connect two browsers directly for peer-to-peer data. You supply the signaling (a WebSocket,
an HTTP endpoint, even a BroadcastChannel between two tabs); the wrapper handles the offer/answer exchange,
ICE, and data channels. Incoming messages and candidates arrive in batches — on the Server host each push
costs a WebSocket frame, so one push per message would end the session under load. The demo runs both peers
in one page, so signaling is a method call and everything else is real.
using Rask.Core.Browser;
namespace Rask.Site.Features;
/// <summary>
/// <see cref="IWebRtc" /> — a peer-to-peer data channel between two browsers. This demo puts
/// <em>both</em> peers in one page, so the signaling step is a plain method call rather than a network
/// hop; in a real app that is exactly where your WebSocket, HTTP endpoint or
/// <c>BroadcastChannel</c> goes. Everything else is the real thing: a real offer/answer
/// exchange, real ICE candidates, and a real <c>RTCDataChannel</c> carrying the messages.
/// <para>
/// Two details are worth copying. Candidates are <b>buffered until the remote description is
/// applied</b> — a candidate that arrives first is rejected by the browser, and this is the single
/// most common way a first WebRTC integration fails. And messages arrive as a <b>batch</b>: the
/// framework coalesces them, because on the Server host one push per message would be one
/// WebSocket frame per message.
/// </para>
/// </summary>
public sealed partial class WebRtcDemo(IWebRtc rtc) : Component, IAsyncDisposable
{
private readonly List<string> _log = [];
private readonly List<RtcIceCandidate> _pendingForCaller = [];
private readonly List<RtcIceCandidate> _pendingForCallee = [];
private IPeerConnection? _caller;
private IPeerConnection? _callee;
private IRtcDataChannel? _chat;
private bool _callerReady;
private bool _calleeReady;
private bool _connecting;
private bool _everConnected;
private int _localCandidates;
private int _sent;
private string _state = "not connected";
private bool _supported = true;
protected override async Task OnFirstRender()
{
_supported = await rtc.IsSupported();
if (!_supported)
{
StateHasChanged();
}
}
protected override Component? Render() =>
Ui.Card[
!_supported
? Div.Class("text-sm text-zinc-500 dark:text-white/70 italic").Id("rtc-state")[
"This browser has no WebRTC support."]
: Div[
Div.Class("flex gap-2 mb-2")[
Ui.Button.Primary
.Id("rtc-connect")
.Disabled(_connecting)
.OnClick(Connect)["Connect the two peers"],
Ui.Button.Filled
.Id("rtc-send")
.Disabled(!_everConnected)
.OnClick(Send)["Send a message"]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70 mb-1")[
"Connection state: ", Span.Id("rtc-state")[_state]],
Div.Class("text-sm text-zinc-500 dark:text-white/70 mb-1")[
"Local ICE candidates gathered: ",
Span.Id("rtc-candidates")[_localCandidates]],
Div.Class("text-sm text-zinc-500 dark:text-white/70 mb-1")["Received by the other peer:"],
MessageLog()
]
];
private Component MessageLog() =>
_log.Count == 0
? Div.Class("text-sm text-zinc-500 dark:text-white/70 italic").Id("rtc-log")["(nothing yet)"]
: Ul.Class("text-sm mb-0").Id("rtc-log")[_log.Select(m => Li.Key(m)[m])];
private async Task Connect()
{
if (_connecting)
{
return;
}
_connecting = true;
_state = "connecting";
StateHasChanged();
// The caller. Its local candidates belong to the callee — in a real app, this is a signaling send.
_caller = await rtc.Create(new RtcConfiguration(), new RtcHandlers
{
OnIceCandidates = candidates => Deliver(candidates, toCaller: false),
OnConnectionStateChanged = state =>
{
_state = state.ToString().ToLowerInvariant();
_everConnected |= state == RtcConnectionState.Connected;
StateHasChanged();
return Task.CompletedTask;
}
});
// The callee. It learns about the channel through OnDataChannel, the way a remote peer always does.
_callee = await rtc.Create(new RtcConfiguration(), new RtcHandlers
{
OnIceCandidates = candidates => Deliver(candidates, toCaller: true),
OnDataChannel = channel => channel.Listen(Receive).AsTask()
});
_chat = await _caller.CreateDataChannel("chat");
await _chat.Listen(Receive);
var offer = await _caller.CreateOffer();
await _caller.SetLocalDescription(offer);
await _callee.SetRemoteDescription(offer);
_calleeReady = true;
var answer = await _callee.CreateAnswer();
await _callee.SetLocalDescription(answer);
await _caller.SetRemoteDescription(answer);
_callerReady = true;
await Flush();
StateHasChanged();
}
// Hands a batch of candidates to the other peer, holding them back until that peer has a remote
// description. addIceCandidate throws before then, and gathering can easily outrun the answer.
private async Task Deliver(IReadOnlyList<RtcIceCandidate> candidates, bool toCaller)
{
var target = toCaller ? _caller : _callee;
var ready = toCaller ? _callerReady : _calleeReady;
var pending = toCaller ? _pendingForCaller : _pendingForCallee;
// Counted for the demo's own display: this is the batch the browser pushed into C#, so a non-zero
// count is proof the whole gather → coalesce → [JSInvokable] → callback path ran.
_localCandidates += candidates.Count;
StateHasChanged();
if (target is null || !ready)
{
pending.AddRange(candidates);
return;
}
foreach (var candidate in candidates)
{
await target.AddIceCandidate(candidate);
}
}
private async Task Flush()
{
await Drain(_pendingForCaller, _caller, _callerReady);
await Drain(_pendingForCallee, _callee, _calleeReady);
return;
static async Task Drain(List<RtcIceCandidate> pending, IPeerConnection? target, bool ready)
{
if (target is null || !ready)
{
return;
}
var buffered = pending.ToArray();
pending.Clear();
foreach (var candidate in buffered)
{
await target.AddIceCandidate(candidate);
}
}
}
private async Task Send()
{
if (_chat is null)
{
return;
}
await _chat.Send($"Message #{++_sent}");
}
// The browser pushes here, so state changes need StateHasChanged() — a subscription, not a binding.
private Task Receive(IReadOnlyList<RtcMessage> messages)
{
foreach (var message in messages)
{
_log.Insert(0, message.Text ?? $"{message.Data?.Length ?? 0} bytes");
}
StateHasChanged();
return Task.CompletedTask;
}
public async ValueTask DisposeAsync()
{
if (_caller is not null)
{
await _caller.DisposeAsync();
}
if (_callee is not null)
{
await _callee.DisposeAsync();
}
}
}ISignaling — the relay two peers trade an offer, an answer and their ICE candidates over, for apps
that don't already have a channel of their own. Host it with AddRaskSignaling() + MapRaskSignaling().
Peer ids are minted by the server, a message only reaches a peer in the sender's own room, and nothing is
ever echoed back to its sender. The demo joins the same room twice from one page, so you can watch the whole
exchange.
using Microsoft.JSInterop;
using Rask.Core.Browser;
namespace Rask.Site.Features;
/// <summary>
/// <see cref="ISignaling" /> — the relay two peers trade an offer, an answer and their ICE candidates
/// over before <see cref="IWebRtc" /> can connect them. This demo opens <em>two</em> connections to the
/// same room from one page, so you can watch the whole exchange: the second one is told who was already
/// there, the first is told someone arrived, and a payload sent to one id comes out at that peer and
/// nowhere else.
/// <para>
/// The payload is an opaque string — here it's plain text; in a real app it's a serialized
/// <c>RtcDescription</c> or <c>RtcIceCandidate</c>. Neither the relay nor the wrapper looks inside.
/// </para>
/// </summary>
public sealed partial class SignalingDemo(ISignaling signaling) : Component, IAsyncDisposable
{
private const string Room = "rask-signaling-demo";
private readonly List<string> _log = [];
private ISignalingConnection? _first;
private ISignalingConnection? _second;
private string? _firstId;
private string? _secondId;
private bool _joining;
private bool _unavailable;
private int _sent;
protected override Component? Render() =>
Ui.Card[
Div.Class("flex gap-2 mb-2")[
Ui.Button.Primary
.Id("signal-join")
.Disabled(_joining)
.OnClick(Join)["Join the room twice"],
Ui.Button.Filled
.Id("signal-send")
.Disabled(_secondId is null)
.OnClick(Send)["Relay a payload"]
],
_unavailable
? Div.Class("text-sm text-zinc-500 dark:text-white/70 italic").Id("signal-status")[
"This host isn't running the relay — it needs AddRaskSignaling() + "
+ "MapRaskSignaling() on the server."]
: Div.Class("text-sm text-zinc-500 dark:text-white/70 mb-1")[
"Peers: ",
Span.Id("signal-peers")[Peers()]],
Div.Class("text-sm text-zinc-500 dark:text-white/70 mb-1")["What the relay reported:"],
_log.Count == 0
? Div.Class("text-sm text-zinc-500 dark:text-white/70 italic").Id("signal-log")["(nothing yet)"]
: Ul.Class("text-sm mb-0").Id("signal-log")[
_log.Select(m => Li.Key(m)[m])
]
];
private string Peers() => _firstId is null ? "none" : $"{Short(_firstId)} + {Short(_secondId)}";
private static string Short(string? id) => id is null ? "?" : id[..Math.Min(6, id.Length)];
private async Task Join()
{
if (_joining)
{
return;
}
_joining = true;
StateHasChanged();
// A host that doesn't map the relay refuses the socket. Say so plainly rather than failing
// silently — the showcase's WASM host serves static files and has no relay to offer.
try
{
await Connect();
}
catch (Exception ex) when (ex is JSException or InvalidOperationException)
{
_unavailable = true;
StateHasChanged();
}
}
private async Task Connect()
{
_first = await signaling.Join(Room, new SignalingHandlers
{
OnJoined = (self, peers) =>
{
_firstId = self;
Log($"first joined as {Short(self)}, saw {peers.Count} peer(s)");
return Task.CompletedTask;
},
// The relay tells everyone already in the room that someone arrived.
OnPeerJoined = id =>
{
Log($"first was told {Short(id)} arrived");
return Task.CompletedTask;
},
OnSignal = (from, payload) =>
{
Log($"first received \"{payload}\" from {Short(from)}");
return Task.CompletedTask;
},
OnError = message =>
{
Log($"relay refused: {message}");
return Task.CompletedTask;
}
});
_second = await signaling.Join(Room, new SignalingHandlers
{
// The peers already present are the ones this connection would offer to — the rule that stops
// both sides offering at once.
OnJoined = (self, peers) =>
{
_secondId = self;
Log($"second joined as {Short(self)}, saw {peers.Count} peer(s)");
return Task.CompletedTask;
},
OnError = message =>
{
Log($"relay refused: {message}");
return Task.CompletedTask;
}
});
}
private async Task Send()
{
if (_second is null || _firstId is null)
{
return;
}
await _second.Send(_firstId, $"payload #{++_sent}");
}
// The relay pushes into these, so state changes need StateHasChanged() — a subscription, not a binding.
private void Log(string line)
{
_log.Insert(0, line);
StateHasChanged();
}
public async ValueTask DisposeAsync()
{
if (_first is not null)
{
await _first.DisposeAsync();
}
if (_second is not null)
{
await _second.DisposeAsync();
}
}
}Notifications and the app badge — await Notification.RequestPermission() (WASM, in the click), then
await using var n = await Notification.Create("Title", new() { Body = "…" }); await Navigator.SetAppBadge(3) and
ClearAppBadge(). All from Rask.Web. A badge only shows on an installed PWA; on iOS it is numeric-only.
using Microsoft.JSInterop;
namespace Rask.Site.Features;
/// <summary>
/// MDN's <c>Notification</c> and <c>navigator.setAppBadge</c> from Rask.Web — raise a local notification and
/// set the app-icon badge from the page (a badge only shows on an installed PWA).
/// </summary>
public sealed partial class NotificationsDemo : Component
{
private string? _status;
protected override Component? Render() =>
Ui.Card[
Div.Class("flex gap-2 flex-wrap items-center mb-2")[
Ui.Button
.Id("notif-permission")
.OnClick(RequestPermission)["Request permission"],
Ui.Button
.Id("notif-show")
.OnClick(Notify)["Notify"],
Ui.Button
.Id("badge-set")
.OnClick(SetBadge)["Set badge 3"],
Ui.Button.Red
.Id("badge-clear")
.OnClick(ClearBadge)["Clear badge"]
],
Div.Class("text-sm text-zinc-500 dark:text-white/70")["Status: ", Code.Id("notif-status")[_status ?? "(idle)"]]
];
private async Task RequestPermission()
{
if (!await Notification.IsSupported)
{
_status = "Notifications not supported on this host";
return;
}
_status = $"Permission: {await Notification.RequestPermission()}";
}
private async Task Notify()
{
if (!await Notification.IsSupported)
{
_status = "Notifications not supported on this host";
return;
}
// Showing without permission throws (matching the browser), so gate on it and prompt the user first.
if (await Notification.Permission != Types.NotificationPermission.Granted)
{
_status = "Grant permission first";
return;
}
await using var shown = await Notification.Create("Rask", new() { Body = "Hello from your Rask app.", Tag = "demo" });
_status = "Notification sent";
}
// setAppBadge is a method, so there is nothing to ask first: a browser without it rejects the call.
private async Task SetBadge()
{
try
{
await Navigator.SetAppBadge(3);
_status = "Badge set to 3";
}
catch (JSException ex)
{
_status = "Badge not supported on this host: " + ex.Message;
}
}
private async Task ClearBadge()
{
try
{
await Navigator.ClearAppBadge();
_status = "Badge cleared";
}
catch (JSException ex)
{
_status = "Badge not supported on this host: " + ex.Message;
}
}
}(idle)Shareable (Rask.Core — all hosts) — headless share: hand your element the data-rask-share
attribute and its click opens the OS share sheet, on every host including Server (the shared client fires
navigator.share in the click gesture, so the activation survives). For a code-driven share in a
WASM app, call await Navigator.Share(…) from Rask.Web instead.
Shareable (Rask.Core) is headless — you render the trigger element, it hands you the data-rask-share attribute to spread onto it. The shared client fires navigator.share inside the click gesture, so the transient user activation survives even on the Server transport (an imperative round-trip would lose it), and it works on every host. For a code-driven share on the WASM host, await Navigator.Share(…) from Rask.Web.
using Rask.Core.Browser;
namespace Rask.Site.Features;
/// <summary>
/// <c>Shareable</c> (Rask.Core) — headless share: it hands <b>your</b> element the
/// <c>data-rask-share</c> attribute, so the click opens the OS share sheet from <b>any</b> host, the
/// Server included. The shared client fires <c>navigator.share</c> inside the gesture (no round-trip, so
/// the activation isn't lost). For a code-driven share on the WebAssembly host, call
/// <c>await Navigator.Share(…)</c> from <c>Rask.Web</c>.
/// </summary>
public sealed partial class ShareDemo : Component
{
protected override Component? Render() =>
Ui.Card[
Div.Class("flex gap-2 flex-wrap items-center mb-2")[
// Headless: we render our own button; Shareable just supplies the share attribute.
Shareable
.Data(new ShareData
{
Title = "Rask",
Text = "Build web apps in C# — one component model, server or WebAssembly.",
Url = "https://github.com/pal-tamas/rask"
})
.Template(share => Ui.Button.Primary
.Id("share-btn")
.Data(share)["Share this page"])
],
Div.Class("text-sm text-zinc-500 dark:text-white/70")[
"Works on every host — the click fires ", Code["navigator.share"],
" inside the gesture (so it works on Server too, where an imperative round-trip would lose "
+ "the activation). Unsupported browsers (e.g. desktop Firefox) no-op."]
];
}navigator.share inside the gesture (so it works on Server too, where an imperative round-trip would lose the activation). Unsupported browsers (e.g. desktop Firefox) no-op.Trigger.Gesture + six typed triggers (Rask.Core — all hosts) — headless gesture bridge: hand your
element the data-rask-gesture attribute and its click runs an activation-gated API in the gesture, so it works
on Server too, where the imperative service can't be injected. Ships Trigger.Fullscreen,
Trigger.ScreenOrientation, Trigger.EyeDropper, Trigger.Install, Trigger.MediaCapture, and
Trigger.PictureInPicture. See Gesture bridge.
The GestureTrigger family (Rask.Core) is headless like Shareable: each trigger hands your element a data-rask-gesture attribute and the shared client runs the activation-gated API inside the click gesture. That makes normally-WASM-only APIs reachable on every host, the Server included — where an imperative RequestFullscreen() / EyeDropper.Open() / … call can't run, because a round-trip would lose the transient user activation. Six typed triggers ship: Trigger.Fullscreen, Trigger.ScreenOrientation, Trigger.EyeDropper, Trigger.Install, Trigger.MediaCapture, and Trigger.PictureInPicture (the last two target a <video> via its ElementRef). Capabilities that return a value (the eyedropper's hex, the install outcome) post it back to the OnColor / OnResult / OnOutcome callback.
using Rask.Core.Components;
namespace Rask.Site.Features;
/// <summary>
/// The full <c>GestureTrigger</c> family (Rask.Core) — the headless gesture bridge. Each trigger hands
/// <b>your</b> element a <c>data-rask-gesture</c> attribute; the shared client runs the activation-gated
/// browser API <b>inside the click gesture</b>, so these work on <b>every</b> host — the Server included,
/// where an imperative <c>RequestFullscreen()</c> / <c>EyeDropper.Open()</c> / … call can't run (a round-trip
/// would lose the transient user activation). <c>FullscreenTrigger</c> and <c>EyeDropperTrigger</c> are
/// joined here by <c>ScreenOrientationTrigger</c>, <c>InstallTrigger</c>, <c>MediaCaptureTrigger</c>, and
/// <c>PictureInPictureTrigger</c> (the last two target a <c><video></c> via its <c>ElementRef</c>).
/// </summary>
public sealed partial class GestureBridgeDemo : Component
{
private readonly ElementRef _preview = ElementRef.New();
private string? _color;
private string? _install;
private Types.MediaStream? _camera;
protected override Component? Render() =>
Ui.Card[
DisplayTriggers(),
EyeDropperRow(),
CameraRow(),
Div.Class("text-sm text-zinc-500 dark:text-white/70 mt-3")[
"Every button runs inside its own click gesture, so they all work on the Server too. ",
"Camera + picture-in-picture need HTTPS and a real device; install needs an installable PWA ",
"(", Code["AddRaskPwa"], "); orientation lock only takes effect while fullscreen (pair it ",
"with the fullscreen button on a phone); the eyedropper needs a Chromium browser. ",
"Stopping the camera stops each track of the stream the capture trigger handed back — ",
Code["MediaStream.From(stream).GetTracks()"], " — releasing the device and its hardware indicator."]
];
private Component DisplayTriggers() =>
Div.Class("flex gap-2 items-center flex-wrap mb-3")[
// Headless: we render our own buttons; the triggers just supply the gesture attribute.
Trigger.Fullscreen
.Template(g =>
Ui.Button.Primary.Id("fullscreen-btn").Data(g)["Enter fullscreen"]),
Trigger.ScreenOrientation
.Orientation("landscape")
.Template(g =>
Ui.Button
.Id("orientation-btn")
.Data(g)["Lock landscape"]),
Trigger.Install
.Template(g =>
Ui.Button.Green
.Id("install-btn")
.Data(g)["Install app"])
.OnOutcome(outcome =>
{
// No StateHasChanged: the trigger is a Component rather than an Element, so its
// callback is auto-wrapped and this demo repaints when the handler returns.
_install = outcome;
return Task.CompletedTask;
}),
_install is null
? Span.Class("text-sm text-zinc-500 dark:text-white/70")["not prompted"]
: Span.Class("text-sm")["install: ", Code.Id("install-outcome")[_install]]
];
private Component EyeDropperRow() =>
Div.Class("flex gap-2 items-center flex-wrap mb-2")[
Trigger.EyeDropper
.Template(g =>
Ui.Button
.Id("eyedropper-btn")
.Data(g)["Pick a colour"])
.OnColor(hex =>
{
_color = hex;
return Task.CompletedTask;
}),
_color is null
? Span.Class("text-sm text-zinc-500 dark:text-white/70")["no colour picked"]
: Span.Class("inline-flex items-center gap-2 text-sm")[
Span
.Id("eyedropper-swatch")
.Style("display:inline-block;width:1.25rem;height:1.25rem;border-radius:.25rem;"
+ $"border:1px solid #ccc;background:{_color}"),
Code.Id("eyedropper-value")[_color]]
];
// Trigger.MediaCapture fills this <video> from the camera; Trigger.PictureInPicture then pops
// that same element out — both resolve the element from its ElementRef.
private Component CameraRow() =>
Div.Class("flex gap-2 items-center flex-wrap items-center")[
// For and Template are the required steps, so they come first: until both are named
// the receiver is still a pending-required wrapper and has no optional setters on it.
Trigger.MediaCapture
.For(_preview)
.Template(g =>
Ui.Button
.Id("camera-btn")
.Data(g)["Start camera"])
.Video()
.FacingMode("user")
// OnStream keeps the started stream reachable from C# — the only way a Server-hosted
// app can hold one, and what makes the stop button below possible at all. No
// StateHasChanged: the trigger is a Component, so its callback is auto-wrapped and
// this demo repaints when the handler returns (RASK026).
.OnStream(stream => _camera = MediaStream.From(stream)),
Ui.Button
.Id("camera-stop-btn")
.Disabled(_camera is null)
.OnClick(StopCamera)["Stop camera"],
Trigger.PictureInPicture
.For(_preview)
.Template(g =>
Ui.Button
.Id("pip-btn")
.Data(g)["Pop out video"]),
Video
.Ref(_preview)
.Id("gesture-preview")
.Muted()
.Style("width:12rem;max-width:100%;border-radius:.25rem;background:#000")
];
// Stopping is not optional: a live stream holds the camera (and its indicator) open until every track
// is stopped, and nothing else in the page will do it.
private async Task StopCamera()
{
if (_camera is null)
{
return;
}
foreach (var track in await _camera.GetTracks())
{
await track.Stop();
await track.DisposeAsync();
}
await _camera.DisposeAsync();
_camera = null;
}
protected override Task OnUnmount() => StopCamera();
}AddRaskPwa); orientation lock only takes effect while fullscreen (pair it with the fullscreen button on a phone); the eyedropper needs a Chromium browser. Stopping the camera stops each track of the stream the capture trigger handed back — MediaStream.From(stream).GetTracks() — releasing the device and its hardware indicator.