Skip to content
Shiny.NET

Blog

Shiny Now Runs on Avalonia and Uno Platform

Shiny’s modules (background jobs, notifications, push, GPS and geofencing, BluetoothLE, HTTP transfers, data sync and the rest) have never really been about MAUI. Shiny.Core has its own platform layer: native directories, main thread, device and app info and lifecycle on every platform, with no MAUI dependency. What MAUI gave Shiny was a host: somewhere to build the container at the right moment and a way to receive the platform events Shiny needs.

Shiny 6.5.3 adds that host for two more UI frameworks:

NuGet package Shiny.Hosting.Avalonia NuGet package Shiny.Hosting.Uno NuGet package Shiny.Hosting.Uno.WebAssembly

A hosting package sounds like plumbing, but two of its jobs decide whether an app actually works.

  1. Start Shiny when the OS starts the process, not when the UI appears. On Android, a background job, a push or a geofence can start your process with no activity at all. If Shiny’s container is only built when your first screen shows, the job never runs and the push is dropped.
  2. Pass the platform events through. On Android that means permission results, activity results and new intents. Without them, every Shiny permission request waits forever and notification taps never reach your delegate.

MAUI does both through UseShiny() and its lifecycle events. Avalonia and Uno each get there differently.

Shiny.Hosting.Avalonia needs Avalonia 12 or later. AppBuilder.UseShiny(...) builds and runs the Shiny host after Avalonia sets up its platform services and before your Application is created, so Host.Current is ready in Initialize() and OnFrameworkInitializationCompleted(). Avalonia has no DI container of its own, so the container Shiny builds is the app’s container. Keep the registrations in one method every head calls:

public static class ShinyStartup
{
public static void Configure(HostBuilder builder)
{
builder.Services.AddSingleton<MyService>();
builder.Services.AddConnectivity();
#if PLATFORM
builder.Services.AddNotifications<MyNotificationDelegate>();
#endif
}
}
// desktop - Program.cs
public static AppBuilder BuildAvaloniaApp() => AppBuilder
.Configure<App>()
.UsePlatformDetect()
.UseShiny(ShinyStartup.Configure);

On Android and iOS, you call it from CustomizeAppBuilder. Avalonia runs that from Application.OnCreate and didFinishLaunching, so Shiny is running when the OS starts the app in the background. On Android, ShinyAvaloniaMainActivity forwards the activity events:

[Application]
public class MainApplication : AvaloniaAndroidApplication<App>
{
public MainApplication(IntPtr handle, JniHandleOwnership ownership) : base(handle, ownership) { }
protected override AppBuilder CustomizeAppBuilder(AppBuilder builder)
=> base.CustomizeAppBuilder(builder).UseShiny(ShinyStartup.Configure);
}
[Activity(MainLauncher = true, LaunchMode = LaunchMode.SingleTop, Theme = "@style/Theme.AppCompat.DayNight.NoActionBar")]
public class MainActivity : ShinyAvaloniaMainActivity { }

Desktop heads target plain net10.0, so they get NetPlatform and the plain .NET version of each module, with Shiny’s main thread set to Avalonia’s Dispatcher.UIThread. On iOS, foreground and background changes and notification taps need no code. Push methods go on your own AvaloniaAppDelegate<TApp>, and only in an app that uses push. See Avalonia Hosting for the details.

Uno apps already have a container: Uno.Extensions.Hosting. Shiny.Hosting.Uno uses it instead of building a second one. UseShiny(...) goes on the host builder, Shiny’s modules are registered next to your own services, and your view models inject IConnectivity, INotificationManager or IJobManager like anything else. Shiny starts through Uno’s IServiceInitialize as soon as the container is built, whether or not the host is ever started.

The background-launch job is where Uno makes it hard. Uno calls OnLaunched only once an activity starts. With the Skia renderer, the Uno 6 template default, it doesn’t even create your App until then. A host built in OnLaunched, which is where the template builds it, doesn’t exist when Android starts the process for a job.

So your App implements IShinyUnoApplication. What the template does between CreateBuilder and NavigateAsync moves into a static ConfigureApp, and Shiny can build from it before there’s an App, a window or an activity:

public partial class App : Application, IShinyUnoApplication
{
protected Window? MainWindow { get; private set; }
protected IHost? Host { get; private set; }
// runs once per process - on Android from Application.OnCreate
public static void ConfigureApp(IApplicationBuilder builder) => builder
.UseToolkitNavigation()
.Configure(host => host
.UseNavigation(RegisterRoutes)
.UseShiny(services =>
{
services.AddConnectivity();
services.AddJobs();
services.AddNotifications<MyNotificationDelegate>();
})
);
protected override async void OnLaunched(LaunchActivatedEventArgs args)
{
var builder = this.GetShinyBuilder(args); // in place of this.CreateBuilder(args)
this.MainWindow = builder.Window;
this.Host = await builder.NavigateAsync<Shell>();
}
}

On Android, one line in your NativeApplication builds the host at process start:

public override void OnCreate()
{
base.OnCreate();
this.StartShiny<App>();
}

When the user then opens the app, GetShinyBuilder hands OnLaunched the same builder, and NavigateAsync gets back the host that’s already running. NavigateAsync, Toolkit navigation and the extended splash screen work as they did. We checked this on an Android emulator: a process started with no activity brings Shiny up, and opening the app later in the same process attaches the window and navigation to that host, with Shiny started once.

Shiny ships no Android base classes for Uno. NativeApplication and ApplicationActivity are different types under Uno’s Skia and native renderers, so a base class built against one wouldn’t load under the other. Your activity keeps its own ApplicationActivity and overrides four methods to forward the events to Host.Lifecycle. The docs page has them ready to copy, along with one flag to set on Android notifications under the Skia renderer.

iOS needs no Shiny code: Uno calls OnLaunched from didFinishLaunching, background launches included. The Skia desktop and Windows heads use Uno’s DispatcherQueue as Shiny’s main thread.

Shiny’s browser modules (battery, connectivity, screen, BluetoothLE, GPS, push and more) are written against Blazor’s IJSRuntime, and Uno’s WebAssembly head doesn’t have Blazor. Shiny.Hosting.Uno.WebAssembly provides one:

.Configure(host => host
.UseNavigation(RegisterRoutes)
#if __WASM__
.UseShinyWebAssembly()
#endif
.UseShiny(services => services.AddConnectivity().AddBattery())
)

It’s built on the .NET WebAssembly runtime’s own [JSImport] / [JSExport] interop and Microsoft.JSInterop’s JS dispatcher. It handles module imports, in-process calls and DotNetObjectReference callbacks, and Shiny’s scripts are served from _content/Shiny.*/ as static web assets, the same way as in Blazor. It also holds the first navigation until the browser monitors have loaded, so the first page shows real connectivity and battery values instead of Unknown. Reference it from the WebAssembly head only. Outside the browser it does nothing.

Each package has a sample in the repo:

  • samples/Sample.Avalonia has desktop, Android and iOS heads.
  • samples/Sample.Uno has Android, iOS, Skia desktop and WebAssembly heads, using the Skia renderer, Uno.Extensions navigation and the Toolkit extended splash.

Both show the platform, connectivity and when Shiny started. On Android and iOS they request notification permission, send a notification and show the tap.

One known gap: Uno 6.7’s iOS runtime has no scene delegate, and the iOS 27 SDK stops apps without one, so the Uno iOS head builds but won’t run on iOS 27 until Uno adds one.

The full setup is in Avalonia Hosting and Uno Platform Hosting, and the changes are in the release notes.

.NET MAUI on the Web - and Most of Shiny Controls Just Works

Platform.Maui.Web is a .NET MAUI backend for the browser. Your MAUI app runs on WebAssembly and renders real DOM elements: <button>, <input>, <dialog>, SVG for shapes and borders, a 2D canvas for GraphicsView. There is no Blazor, no Razor components and no canvas-drawn UI. MAUI’s own cross-platform layout engine positions everything, so Grid, FlexLayout, AbsoluteLayout and your custom layouts behave the way they do on every other platform.

NuGet package Platform.Maui.Web NuGet package Platform.Maui.Web.Essentials

It lives in shinyorg/mauiplatforms next to the Apple TV backend, and it follows the structure and lessons of the backends in dotnet/maui-labs (Linux GTK4, macOS AppKit). It’s experimental: the current release is 0.4.0, and it stays below 1.0 while the API settles.

The MAUI Web gallery sample running in the browser: a FlyoutPage menu with emoji icon tiles and a live GraphicsView hero

The real test of a new MAUI backend isn’t its own sample, though. It’s somebody else’s app. So we took the biggest MAUI app we have, the Shiny Controls gallery, and pointed it at the browser.

The gallery has a Linux GTK4 head that compiles the shared pages on plain net10.0. The web head is the same idea: a Microsoft.NET.Sdk.WebAssembly project that links the gallery’s XAML and C# and swaps UseMauiAppLinuxGtk4<App>() for UseMauiAppWeb<App>(). No page was changed for the browser.

The result: 68 demo pages, published as a trimmed Release build, and every one of them opens without an exception. We drove each page in Chromium with Playwright and checked the console as well as the screen.

The Shiny Controls gallery home page running in the browser on Platform.Maui.Web

What renders and responds:

Area Controls
Data & layout DataGrid and TreeDataGrid, TreeView, TableView (all 14 cell types, dynamic sections, drag-to-sort), Expander, Docking, CoverFlow, Parallax CollectionView, ShinyFlexLayout, YogaLayout
Scheduler Calendar, Agenda, Calendar Picker, Event List
Input Text entry (M3 floating labels), AutoComplete, TagEntry and ChipGroup, Color Picker, Country & Address, Duration Picker, Font Picker, Slider, Range Slider, Range Pickers, Security Pin, Password Strength, Signature Pad, On-Screen Keyboard, Keyboard Shortcuts
Actions & navigation ShinyButton, ButtonGroup, FAB and FabMenu, StateView, Wizard, Walkthrough, Tooltip
Status & feedback Pills, Badge, Avatar, Notch, Toast, Dialogs, Feedback, Progress Bar, Gauge, Skeleton
Animation Confetti, Border Animations, Motion Icons
Content Markdown Viewer and Editor, Mermaid diagrams (flowcharts, directions, themes, subgraphs, the interactive editor)
Overlays & media Overlay, Frosted Glass, Image Viewer, Image Gallery, Media Picker
Shiny DataGrid in the browser Shiny Scheduler calendar in the browser
Shiny Docking panels in the browser Shiny Gauge dials in the browser
A Mermaid flowchart rendered by Shiny MermaidDiagrams in the browser Shiny CoverFlow in the browser

Why does so much of it work? Most Shiny Controls are composed from MAUI primitives: Grid, Border, Label, GraphicsView, shapes, gestures and animations. The web backend implements those primitives with real DOM elements, so a control built on them comes along for free. Desktop-only features degrade the way they do on any platform without them: the System Tray page, for example, says tray icons aren’t available on this platform.

Controls that bring their own native view need a browser handler before they can run, and some of those don’t have one yet:

  • MediaElement. Shiny’s MediaElement has native handlers (Media3/ExoPlayer, AVPlayer, GStreamer on Linux), but none for the browser yet. An HTML <video> element is the obvious fit.
  • SkiaSharp views. SKCanvasView has no web handler, so everything that paints with Skia waits on it: the Office editors (spreadsheet, documents, slides, notebooks) and FloorPlan.
  • CarouselGallery, StaggeredGrid and VirtualizedGrid. Their handlers wrap RecyclerView, UICollectionView and ScrollViewer, with nothing for the browser yet, so the web head leaves them out of its menu.
  • Images inside GraphicsView. The browser decodes and draws them, but MAUI’s net10.0 PlatformImage can’t, so it reports the wrong size. The ImageEditor canvas shows up black for this reason.
  • Remote ShinyImage downloads. On the MAUI host, ShinyImage downloads through HttpClient, which in the browser is fetch and subject to CORS. Servers that don’t send CORS headers fail, and the progress ring doesn’t finish yet.
  • Not tried yet. The web head leaves out the same pages as the Linux head: Camera and Barcodes, Chat and the speech add-ins, FloatingPanel, Keyframe, Gamepad and the Diagram add-on. Some of these have browser APIs to build on (getUserMedia, BarcodeDetector, the Gamepad API), and Shiny’s Blazor controls already use them.

For the backend itself, the web guide lists the rest. The two you’re most likely to hit are that CollectionView isn’t virtualized yet and that pinch gestures and drag & drop aren’t implemented.

You need the .NET 10 SDK and nothing else (no workload). Create a Microsoft.NET.Sdk.WebAssembly project:

<Project Sdk="Microsoft.NET.Sdk.WebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ApplicationTitle>My App</ApplicationTitle>
<ApplicationId>com.example.myapp</ApplicationId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Platform.Maui.Web" Version="0.4.0" />
<PackageReference Include="Platform.Maui.Web.Essentials" Version="0.4.0" />
<PackageReference Include="Shiny.Maui.Controls" Version="*-*" />
</ItemGroup>
</Project>

Then register the backend instead of UseMauiApp<App>(), and add Shiny Controls exactly as you would on a phone:

public class MyWebApp : WebMauiApplication
{
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
public static class Program
{
public static Task Main() => new MyWebApp().RunAsync();
}
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiAppWeb<App>() // instead of UseMauiApp<App>()
.UseWebEssentials()
.UseShinyControls()
.ConfigureFonts(f => f.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"));
return builder.Build();
}
}

Add a wwwroot/index.html that loads a main.js (dotnet.create() then runMain()), and run dotnet run, or dotnet publish -c Release for static files you can host anywhere. The setup guide has every file.

XAML needs nothing web-specific. Keep your app project as it is and add a web project beside it that links the shared XAML and C#, leaving the Platforms folder and the app’s MauiProgram.cs behind. That’s how the Shiny Controls web head is built, and the web guide has a complete project file. Some things we learned doing it:

  • OnPlatform matches Web, and #if ANDROID/#if IOS blocks compile with neither defined (WEB and BROWSER are).
  • Trimming is on in Release. Prefer compiled bindings (x:DataType), and use IQueryAttributable rather than [QueryProperty], which MAUI turns off in trimmed builds.
  • The browser serves images and raw assets from the same root. If a MauiImage and a MauiAsset share a file name, as the Shiny gallery’s two copies of dotnet_bot.png did, the build stops with a static web asset conflict. Rename or exclude one of them.
  • Remote images and HTTP calls go through fetch, so the server you call needs to send CORS headers.

MediaElement on <video>, a SkiaSharp view, browser handlers for the three native collection controls, and virtualization in CollectionView are the gaps that matter most to Shiny Controls. If there’s a control you need in the browser, open an issue on shinyorg/mauiplatforms.

Universal Hubs - Write the Hub Once, Connect Over Bluetooth or Wi-Fi

Two weeks ago we shipped Shiny.BluetoothLE.Hubs, which puts the SignalR hub model on Bluetooth LE. Shiny.UniversalHubs is the next step. It serves the same hub over Wi-Fi as well as BLE, and you don’t change your contract, your hub or your app code to get it.

NuGet package Shiny.UniversalHubs NuGet package Shiny.UniversalHubs.Host NuGet package Shiny.UniversalHubs.Client

On a plane, the players connect over BLE. At home, they connect over Wi-Fi, which has more range, more bandwidth and no MTU. A player on Wi-Fi and a spectator on Bluetooth can be in the same game, in the same groups, getting the same pushes.

It targets .NET 10, runs on iOS, Android and macOS, and is AOT- and trim-safe with no reflection.

BLE Hubs is great when there’s no network at all, but Bluetooth has hard limits:

  • Range. A few metres to a room. Walk to the kitchen and you’re gone.
  • Bandwidth. GATT runs at a few KB/s. Fine for moves and state, slow for anything bigger.
  • MTU. Every message is chunked to fit MTU - 3 bytes, and reassembled on the other side.
  • Distance only. Nobody outside the room can join.

When the devices share a network, Wi-Fi fixes all of that. But you don’t want two apps, two contracts or two sets of hub code. You want the hub you already wrote to be reachable both ways, and the client to pick.

BLE Hubs Universal Hubs
Transports Bluetooth LE Bluetooth LE and Wi-Fi (HTTP), at the same time
Discovery BLE scan BLE scan and mDNS, merged into one list
Hub base class BleHub<T> UniversalHub<T>, which knows each connection’s transport
Files L2CAP L2CAP over BLE, plain HTTP over Wi-Fi, same UploadFile / DownloadFile
Dropped links a disconnect a short Wi-Fi drop resumes: same connection id, same groups, missed pushes replayed
Beyond the room no opt-in tunnels to the internet, per hub

Everything else is still blehubs: the [BleHubClient] contract, the generated proxy, Clients.All / Group(...), IHubContext<THub>, streaming, cancellation and typed disconnect reasons. Universal Hubs only carries those calls over a second transport. It doesn’t reimplement the hub.

One call sets up both sides. Each hub gets a characteristic (for BLE) and an mDNS service type (for Wi-Fi):

services.AddUniversalServer(server => server
.ServiceUuid(ServiceUuid) // optional - the one BLE service
.Ble(o => o.EnableFileTransfers(filesDir)) // L2CAP and HTTP files both use it
.Http(o => o.AuthorizeFile = req => req.FileName.StartsWith("avatar-"))
.AddHub<GameHub>(CharacteristicUuid, "_mygame._tcp", o => o.MaxClients = 6) // limits cover both transports
);

The HTTP server binds a free port on every interface and mDNS tells clients where it is, so there’s nothing to configure. IUniversalHubHost starts and stops both transports:

public class Lobby(IUniversalHubHost host)
{
// BLE, Wi-Fi or both - whichever this device can do right now
public Task<HubTransports> Open() => host.Start(HubTransports.All);
public Task Close() => host.Stop("The host ended the game");
}

Your hub can stay a BleHub<T>, but derive it from UniversalHub<T> and its lifecycle hooks get a HubConnection that says which transport each client is on:

public class GameHub : UniversalHub<IGameHub>
{
public override Task OnConnectedAsync(HubConnection connection)
{
// connection.Transport: HubTransports.Ble or HubTransports.Http
// connection.Ble: the BLE central, connection.Http: the Switchboard caller (null when they don't apply)
// connection.Properties: your own per-connection state, the same bag on both transports
return Groups.AddToGroupAsync(connection.Id, "lobby");
}
public override Task OnDisconnectedAsync(HubConnection connection, HubDisconnect disconnect)
=> Clients.Others.Emote(connection.Client.Name ?? "?", disconnect.Reason.ToString());
public async Task<MoveResult> MakeMove(int cell)
{
// ...
await Clients.All.StateChanged(engine.Snapshot()); // BLE and Wi-Fi clients alike, in order
return new MoveResult(true, null);
}
}

Clients.All, groups, IHubContext<THub>, MaxClients, ValidateClient and Context.Abort all cover BLE and Wi-Fi clients together.

services.AddUniversalHubClient<IGameHub>(CharacteristicUuid, "_mygame._tcp",
o => o.ServiceUuid = ServiceUuid);
public class JoinViewModel(IUniversalHubClient<IGameHub> client)
{
// a host seen over BLE and Wi-Fi is one HubHostInfo
IDisposable Scan() => client.DiscoverAll().Subscribe(host => /* host.Name, host.Transports, host.Rssi */);
async Task Join(HubHostInfo host)
{
await client.Connect(host, new HubConnectOptions("Allan") { Prefer = HubTransports.Http });
// client.Transport says which one was used
await client.Hub.MakeMove(4);
}
}
  • One list. BLE scan results and mDNS results are merged, so a host reachable both ways shows up once with both transports.
  • Prefer and fall back. Prefer is tried first, and the other transport is the fallback. Allowed limits which transports may be used at all. A refusal (host full, ValidateClient) isn’t retried on the other transport.
  • Nothing else changes. IUniversalHubClient<T> is an IBleHubClient<T>. The generated proxy, events, Disconnected, streams and file methods work the same over either transport.

Over Wi-Fi, each client holds a Switchboard line from Shiny.Net.HttpServer: server-sent events down, POSTs up, with a resume window. If a phone’s Wi-Fi drops for a moment, the line comes back with the same id, the same groups, and the pushes it missed are replayed. The hub never sees a disconnect. Only a line that’s gone for good runs OnDisconnectedAsync, as ClientTimeout.

Disconnect reasons mean the same thing on both transports and on both sides: ClientDisconnect, ClientTimeout, ServerDisconnect, ServerShutdown or ConnectionFailed.

Start/Stop run every hub. StartHub<THub>()/StopHub<THub>() run one, on both transports, so an always-on hub can sit beside one that only runs on demand:

await host.StartHub<ChatHub>(); // at app start, and left running
await host.StartHub<GameHub>(); // when the player taps Host
await host.StopHub<GameHub>("Game over"); // its clients get ServerShutdown; the chat carries on

The HTTP server starts with the first hub hosted over HTTP and stops with the last, and mDNS only advertises the hubs that are running.

Because the Wi-Fi side is a real HTTP server, it can be put on the internet with any Shiny.Net.HttpServer tunnel. That exposes the whole server, so each hub opts in:

services.AddUniversalServer(server => server
.HttpServer(http => http.AddQuickTunnel()) // Shiny.Net.HttpServer.Ssh
.AddHub<GameHub>(GameCharacteristic, "_mygame._tcp") // 404 through the tunnel
.AddHub<ChatHub>(ChatCharacteristic, "_mychat._tcp", o => o.AllowTunnel = true));

A tunnel URL has no mDNS, so a remote client connects by address. FromUrl works for typed addresses and QR codes too:

await client.Connect(HubHostInfo.FromUrl(new Uri("https://abc.a.pinggy.link")));

Put authentication in front of anything you share on the internet.

UploadFile and DownloadFile don’t change. Over BLE they use an L2CAP channel. Over Wi-Fi they’re plain HTTP PUT / GET requests, tied to the client by a token from the handshake. Both follow the same EnableFileTransfers settings, plus an optional AuthorizeFile rule.

  • Hub logic stays in blehubs. blehubs gained two hidden transport seams: a transport endpoint on the host and an external transport on the client. The generated dispatcher, groups, ordered pushes, timeouts and payload limits all run in blehubs, whatever the transport.
  • The wire. Each hub is a Switchboard at /hubs/{characteristicUuid}, with a few reserved methods: hub.handshake, hub.invoke, hub.stream and hub.push. The wire is versioned, and the handshake refuses mismatches.
  • JSON is re-framed, never re-serialized. The bytes IBleHubSerializer produced for BLE are carried as JSON values over Wi-Fi. Your JsonSerializerContext covers both transports, and nothing uses reflection.
  • Remote errors survive. A hub exception still arrives as BleHubRemoteException, with its type and message.
  • mDNS TXT carries the protocol version, host id, name, base path and the running hubs, so clients filter for the hubs they speak before connecting.
  • One BLE service UUID per host. Every hub is a characteristic in it, because two 128-bit UUIDs don’t fit in a 31-byte advertisement.
  • Plane, hotel and public Wi-Fi usually isolate clients from each other, which blocks mDNS and device-to-device traffic. That’s exactly why BLE is the fallback.
  • Hosting runs while the app is in the foreground, the same as BLE hosting.
  • iOS needs NSLocalNetworkUsageDescription, your service types in NSBonjourServices, and NSAllowsLocalNetworking on clients. Android needs the multicast and network permissions and cleartext traffic. The Getting Started page has the full list.
  • The transport is chosen at connect time. Moving a live session from BLE to Wi-Fi as someone walks in the door is on the list for later.

The repo’s sample is the BLE Hubs tic-tac-toe game, hosting over both transports. The join list shows how each game is reachable, and the game screen says which transport is in use. Next to it runs an always-on chat hub, so anyone nearby, on the network or (through a quick tunnel) on the internet can message the device’s owner. The same app runs on iOS, Android and macOS.

  • Getting Started: packages, setup and platform permissions
  • Hosting: AddUniversalServer, UniversalHub<T>, start/stop, tunnels and disconnect reasons
  • Client: discovery, transport choice, resume, FromUrl and files
  • How It Works: the wire protocol, mDNS TXT and file routes
  • BluetoothLE Hubs: contracts, hubs, groups and everything else your hub does

Shiny.Net.Http - The Mobile Library Workhorse You Didn't Know You Needed

Most libraries in Shiny get noticed because of what they do: BLE, GPS, push, health data. Shiny.Net.Http mostly gets noticed after it is already in the app, quietly doing the job that was never in the spec - moving a file while the user is somewhere else.

NuGet package Shiny.Net.Http

Look at a list of real apps and count the ones that never move a file:

  • a field-inspection app uploading photos and signed PDFs from a job site
  • a dash-cam or body-cam app shipping video clips home overnight
  • a logistics app sending proof-of-delivery images on a patchy cellular link
  • an offline-maps or training app pulling down hundreds of megabytes of content packs
  • an on-device AI app downloading a model file the first time it runs
  • any app uploading diagnostic logs, backups or database snapshots

All of them start with HttpClient, and HttpClient works perfectly - on a desk, with the screen on. Then someone tests on a phone in a pocket. iOS suspends the process seconds after the app leaves the foreground and the socket dies with it. Android reclaims backgrounded processes under memory pressure. The 200 MB upload restarts from zero next time the app opens, on the user’s data plan.

The fix is not a better retry loop inside your process. It is to stop being the process holding the socket: hand the transfer to whatever the platform runs on your behalf. That is what Shiny.Net.Http does.

This is the oldest code in the Shiny family. It started in 2016 as a Xamarin plugin, before Shiny existed, back when “background upload on iOS and Android” meant reading two sets of platform docs and writing everything twice. It became a Shiny module when Shiny launched in 2019, and it has been rewritten and re-platformed many times since: Xamarin to .NET MAUI, iOS background session rewrites, an Android engine that now lives in a foreground service, and a managed engine that brought the same API to Windows, Linux, macOS and Blazor WebAssembly.

The surface you call has barely changed in all that time. What sits under it has absorbed a decade of OS-level changes so your app does not have to.

The “just use HttpClient” version of this feature grows a long tail. Shiny.Net.Http already covers it:

You need Shiny.Net.Http gives you
Transfers that survive suspension Background NSURLSession on iOS/Mac Catalyst (the OS relaunches your app when it finishes), a foreground service on Android, Service Worker Background Sync on Blazor, a connectivity-aware managed loop on desktop/Linux
A queue that survives app restarts Transfers are persisted in a Shiny IRepository
Network awareness PausedByNoNetwork, and UseMeteredConnection = false keeps big transfers off cellular (PausedByCostedNetwork)
Pause / resume / cancel Pause, Resume, Cancel, CancelAll - downloads resume with an HTTP Range request
Retries and expired tokens HttpTransferDelegate with maxErrorRetries, OnBeforeRetry and OnAuthorizationFailed to refresh a bearer token and re-queue
Resumable uploads TransferType.UploadTus via TusUploadRequest for any tus 1.0 server
Cloud storage AzureBlobStorageUploadRequest and AwsS3UploadRequest sign requests without pulling in the vendor SDKs
Progress for your UI Events, an awaitable watch, a bindable monitor and system progress surfaces - see below

Getting started is two calls:

builder.Services.AddHttpTransfers<MyTransferDelegate>();
await transferManager.Queue(new HttpTransferRequest(
"inspection-1234",
"https://api.example.com/inspections/1234/photos",
TransferType.UploadMultipart,
photoPath
));

When it finishes - even if the app was suspended or terminated in between - your delegate hears about it:

public partial class MyTransferDelegate(
ILogger<MyTransferDelegate> logger,
IHttpTransferManager manager
) : HttpTransferDelegate(logger, manager, maxErrorRetries: 3)
{
public override Task OnCompleted(HttpTransferRequest request)
{
// mark the record as synced, clean up the local file, etc.
return Task.CompletedTask;
}
}

A background transfer does not need a screen, and nothing about Shiny.Net.Http requires one. But when a screen does want to show what is moving, there are four levels to pick from, and every one reports the same TransferProgress: bytes transferred, total bytes, bytes per second, PercentComplete and EstimatedTimeRemaining.

Every transfer, every update:

manager.UpdateReceived += (_, result) =>
{
if (result.IsDeterministic)
Console.WriteLine($"{result.Request.Identifier}: {result.Progress.PercentComplete:P0} - {result.Progress.EstimatedTimeRemaining}");
};

Throughput is measured consistently across platforms (and taken straight from NSProgress on Apple), and update frequency is bounded at the source so a fast connection does not flood your UI thread.

When a flow just needs to know when this file is done:

await manager.Queue(request);
var result = await manager.WatchTransfer(request.Identifier);
if (result.Status == HttpTransferState.Completed)
await this.Navigation.GoToAsync("done");

HttpTransferMonitor gives you a collection of HttpTransferObject - each one INotifyPropertyChanged with PercentComplete, BytesPerSecond, EstimatedTimeRemaining and Status - seeded from the persisted queue, so a relaunched app shows bars already in the right place:

public class TransfersViewModel(HttpTransferMonitor monitor)
{
public INotifyReadOnlyCollection<HttpTransferObject> Transfers => monitor.Transfers;
public Task Start() => monitor.Start(
removeFinished: true,
syncContext: SynchronizationContext.Current
);
public Task Pause(HttpTransferObject item) => monitor.Pause(item.Identifier);
public Task Resume(HttpTransferObject item) => monitor.Resume(item.Identifier);
}
<CollectionView ItemsSource="{Binding Transfers}">
<CollectionView.ItemTemplate>
<DataTemplate>
<VerticalStackLayout>
<Label Text="{Binding Identifier}" />
<ProgressBar Progress="{Binding PercentComplete}" />
<Label Text="{Binding EstimatedTimeRemaining, StringFormat='{0:mm\\:ss} left'}" />
</VerticalStackLayout>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>

The whole premise is that the user is not in your app, so the most useful progress bar is often the one on the Lock Screen. One registration puts transfers on an iOS Live Activity (Lock Screen and Dynamic Island) or the Android foreground-service notification (a live update with a status bar chip on Android 16):

builder.Services.AddTransferProgress(opts =>
{
opts.Scope = TransferProgressScope.Summary; // one surface for the whole batch, or PerTransfer
opts.Fields = TransferProgressFields.Default; // file, direction, %, bytes, speed, ETA
opts.ShortStatus = TransferProgressShortStatus.Percent;
});

The August deep dive covers how that works - including why an iOS Live Activity has to animate a time range rather than a percentage while your app is suspended.

iOS, Mac Catalyst, Android, Windows, Linux, macOS, plain .NET and Blazor WebAssembly - trimmable and Native AOT compatible.

BluetoothLE between apps has never been easier - Enter BLE Hubs

Shiny.BluetoothLE.Hubs is a new library that brings the SignalR hub model to Bluetooth LE. One device hosts a hub. Nearby devices discover it, connect, and call it through a source-generated, strongly typed proxy. The host pushes events back to every client, to one client, to everyone else, or to a group. You don’t need a server, a network or Wi-Fi, only phones in the same room.

NuGet package Shiny.BluetoothLE.Hubs NuGet package Shiny.BluetoothLE.Hubs.Host NuGet package Shiny.BluetoothLE.Hubs.Client

It builds on BluetoothLE on the client side and BluetoothLE Hosting on the host side. It targets .NET 10, runs on iOS, Android and macOS, and is AOT- and trim-safe with no reflection.

Getting two of your own apps to talk over Bluetooth LE looks simple. One phone is a peripheral with a GATT service and the other connects to it. In practice, you end up building a protocol stack inside the app:

  • Tiny packets. A write or notification carries MTU - 3 bytes. That is 20 bytes at the BLE minimum and a few hundred bytes after negotiation. Any real payload has to be chunked on one side and reassembled on the other.
  • No request/response. GATT gives you writes, reads and notifications, not calls. To get an answer you have to invent message ids, match replies to requests, and handle the reply that never arrives.
  • Concurrency. Two calls in flight at once means their frames must never interleave, and every reply must find the right caller.
  • Events. A host that wants to tell clients “the board changed” needs its own message kinds, routing to the right clients, and ordered delivery on the client.
  • Serialization. Every type needs a byte format on both sides, and in an AOT/trimmed app that format can’t use reflection.
  • Lifecycle. iOS peripherals can’t disconnect a central. Android doesn’t always report that a client went away. Somebody has to decide what “disconnected” means and tell both sides why it happened.

That is a lot of work before the app does anything useful.

If you’ve used ASP.NET Core SignalR, you already know this library. Ours is close on purpose:

SignalR BLE Hubs
Hub<TClient> BleHub<TContract>
Clients.All / Others / Caller / Group(...) the same, with typed pushes
Groups.AddToGroupAsync the same
OnConnectedAsync / OnDisconnectedAsync the same, with a typed HubDisconnect reason
IHubContext<THub> the same, plus per-hub Start() / Stop()
Context.ConnectionId, Context.Abort() the same
IAsyncEnumerable<T> streaming the same, and cancellation reaches the hub
A new hub instance in its own DI scope per call the same
HubConnection + On<T>("Name", ...) a generated typed proxy with real C# events

The main difference is the contract. SignalR clients use strings: connection.On<GameState>("StateChanged", ...) and InvokeAsync("MakeMove", 4). BLE Hubs uses one interface that both sides compile against, and a source generator writes the rest.

[BleHubClient]
public interface IGameHub
{
// client -> host: request / response
Task<JoinResult> Join(string playerName, string? avatarFile);
Task<MoveResult> MakeMove(int cell);
Task Rematch();
// client -> host: a stream, cancellable from the client
IAsyncEnumerable<int> Countdown(int from, CancellationToken cancellationToken);
// host -> clients: events
event Action<GameState> StateChanged;
event Action<string, string> Emote;
}
  • Methods are client → host calls. They return Task, Task<T> or IAsyncEnumerable<T>. A trailing CancellationToken is passed through to the host and is never serialized.
  • Events are host → client pushes, declared as Action up to Action<T1..T4>.

From that interface the generator emits:

  • the client proxy (GameHubClient), which implements IGameHub
  • the hub dispatcher, which switches on the method name, reads typed arguments and calls your method
  • typed push methods, so you write Clients.All.StateChanged(state) instead of SendAsync("StateChanged", state)
  • an IHubContext<GameHub>.Clients C# 14 extension property, for pushing from outside a hub

Mistakes are compile errors, not runtime surprises. SBH001 to SBH006 cover a hub that doesn’t match its contract, unsupported return types, overloads, unsupported event delegates and ref/out parameters.

public class GameHub(GameEngine engine) : BleHub<IGameHub>
{
public override Task OnConnectedAsync()
=> Groups.AddToGroupAsync(Context.ConnectionId, "lobby");
public async Task<MoveResult> MakeMove(int cell)
{
var error = engine.TryMove(engine.GetMark(Context.ConnectionId), cell);
if (error != null)
return new MoveResult(false, error); // the reply
await Clients.All.StateChanged(engine.Snapshot()); // the event, typed
await Clients.Group("spectators").Emote("host", "👀");
return new MoveResult(true, null);
}
public async IAsyncEnumerable<int> Countdown(int from, [EnumeratorCancellation] CancellationToken ct)
{
for (var i = from; i > 0; i--)
{
yield return i;
await Task.Delay(1000, ct); // the client's cancel arrives here
}
}
public override Task OnDisconnectedAsync(HubDisconnect disconnect)
=> Clients.Others.Emote(Context.Client.Name ?? "?", disconnect.Reason == HubDisconnectReason.ClientTimeout
? "📡 lost connection"
: "🚪 left");
// Join, Rematch... (SBH001 tells you if one is missing)
}
builder.Services.AddBluetoothLeHosting();
builder.Services.AddBleHub<GameHub>(ServiceUuid, CharacteristicUuid, o => o.MaxClients = 6);
await host.Start(); // IBleHubHost: GATT service, advertising, L2CAP

To push from somewhere that isn’t a hub, such as a timer or a background service, inject IHubContext<GameHub>:

public class Ticker(IHubContext<GameHub> hub)
{
public Task Tick(GameState state) => hub.Clients.All.StateChanged(state);
}
builder.Services.AddBluetoothLE();
builder.Services.AddBleHubClient<IGameHub>(ServiceUuid, CharacteristicUuid);
public class GameViewModel(IBleHubClient<IGameHub> client)
{
async Task Start()
{
// events are plain C# events
client.Hub.StateChanged += state => MainThread.BeginInvokeOnMainThread(() => Apply(state));
client.Disconnected += (_, d) => Show($"{d.Reason}: {d.Description}");
var host = await client.Discover().FirstAsync();
await client.Connect(host, new BleHubConnectOptions("Allan"));
// request / response is just a method call
var result = await client.Hub.MakeMove(4);
// streams are await foreach, and break cancels the host's method
await foreach (var n in client.Hub.Countdown(10, ct))
if (n == 3) break;
}
}

That’s all the code there is. You don’t write chunking, message ids or byte arrays.

You never have to know any of this, but here is what the library does for you.

GATT has no calls, so BLE Hubs uses one characteristic per hub, with Write for client → host and Notify for host → client. Each call gets a message id, and the host’s Completion (or Error) frame carries that id back. Several calls can be in flight at once and each reply finds its caller. Calls time out after RequestTimeout (30 seconds by default). A host exception arrives as BleHubRemoteException, and a dropped link fails pending calls with BleHubDisconnectedException, which says why.

We use notifications for replies rather than reads on purpose. With reads, the client can’t tell when a reply is ready, can’t tell which call a value belongs to, and is still capped by the MTU.

A push is its own frame kind, sent by name with its arguments. Clients.All, Others, Caller, Client(id), Group(name), GroupExcept(...) and the rest are resolved on the host. On the client, pushes go through a channel and are raised one at a time, in the order the host sent them, so a StateChanged never overtakes the previous one.

Messaging and JSON, without touching bytes

Section titled “Messaging and JSON, without touching bytes”

Every argument, result, stream item and event value is serialized on its own, with its static type, through IBleHubSerializer. The default uses Shiny’s AOT-friendly System.Text.Json, so you register a JsonSerializerContext once on both sides:

[JsonSerializable(typeof(JoinResult))]
[JsonSerializable(typeof(MoveResult))]
[JsonSerializable(typeof(GameState))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(int))]
public partial class GameJsonContext : JsonSerializerContext;
Shiny.Json.AddContext(GameJsonContext.Default);

To use MessagePack, protobuf or something else, register your own IBleHubSerializer.

The serialized message is then framed to fit the negotiated MTU. The client requests 512 bytes. Each frame carries a small header with the protocol version, kind, message id, sequence, first/last flags and, on the first frame, the total length. The receiver reassembles by message id, with limits on message size (256 KB by default), reassembly time and partial messages per client, so a broken or hostile peer can’t exhaust memory. The host sends each message to a client as a whole, under a lock, so frames for one client never interleave.

  • When a client connects, a handshake exchanges the protocol version, the client’s name and properties, and the file transfer channel. Mismatched versions are refused.
  • A client is registered, and OnConnectedAsync has run, before the handshake reply goes out, so the first call always finds it.
  • Disconnects are cooperative, because iOS peripherals can’t drop a central. The host sends a Disconnect frame and the client library leaves.
  • Every departure comes with a typed HubDisconnect reason on both sides: ClientDisconnect, ClientTimeout, ServerDisconnect, ServerShutdown or ConnectionFailed. The client says goodbye before it unsubscribes, so the host can tell a player who left from one whose phone walked out of range.
  • A periodic sweep catches Android clients that disappear without an unsubscribe.

GATT gives you a few KB/s, which is plenty for moves, commands and state but not for photos. File transfers use a separate L2CAP channel, a direct stream between the devices. The host advertises it in the handshake, so the client just calls:

await client.UploadFile(path, "avatar.jpg");
await client.DownloadFile("avatar.jpg", localPath);
builder.Services.ConfigureBleHubHost(o => o.EnableFileTransfers(Path.Combine(FileSystem.AppDataDirectory, "files")));

A device can host several hubs. Each hub is its own characteristic, and hubs that share a service UUID share one GATT service, so the 31-byte advertisement stays small. Several hub clients connected to the same host share one BLE connection. You can start and stop each hub on its own with IHubContext<THub>.Start() / Stop(reason). A stopped hub disconnects its clients and refuses new ones, while the other hubs keep running.

The repo’s samples/TicTacToe is a .NET MAUI app for iOS and Android. One phone taps Host a game and plays X. The next phone to join plays O, and any phones after that join the spectators group. Moves are hub calls, board updates and emotes are pushes, and avatars travel over L2CAP. You need two physical devices, because simulators and emulators don’t have usable Bluetooth.

  • Getting Started: packages, setup and platform permissions
  • Contracts: [BleHubClient], serialization, generated code and diagnostics
  • Hosting: BleHub<T>, IHubContext, groups, start/stop
  • Client: discovery, calls, events and failures
  • Files: L2CAP transfers
  • How It Works: GATT layout, framing, limits and best practices