Skip to content
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

Mobile

Frameworks
.NET MAUI
Operating Systems
Android
iOS
macOS
Windows

This is the case the library exists for: ASP.NET Core does not run in a .NET MAUI app, and this does. A phone hosting its own API, its own configuration page, its own file access — and, through a tunnel, reachable from anywhere.

builder.Services.AddShinyHttpServer(
http =>
{
// Any, not loopback: the point is for another device to reach this one.
// Port 0 lets the OS pick, so two copies of the app never collide.
http.Options.Address = IPAddress.Any;
http.Options.Port = 0;
http.AddAuthentication().AddBasic<CredentialStore>(o => o.Realm = "Device");
http.Configure(server =>
{
server.UseAuthentication();
server.UseEmbeddedFiles(typeof(MauiProgram).Assembly, "MyApp.wwwroot");
server.MapMyAppEndpoints();
});
},
// Started by the UI instead, so the app does not open a port before anyone asked it to.
autoStart: false
);

Everything the server needs is registered on the builder inside that callback — authentication, health checks, tunnels, discovery — rather than scattered across builder.Services. See Hosting & Lifecycle.

Then, from a toggle:

public sealed class ShareViewModel(HttpServer server)
{
public async Task StartAsync()
{
await server.StartAsync();
this.LocalUrl = server.ListenUrl;
}
}

server.StateChanged gives the UI its transitions without polling. See Hosting & Lifecycle.

Terminal window
dotnet add package Shiny.Net.HttpServer.Mobile
builder.Services.AddShinyHttpServer(http =>
{
http.AddHttpServerLifecycle(o =>
{
o.BackgroundMode = BackgroundServerMode.Stop; // Stop | KeepAlive | Leave
o.RestartOnConnectivityChange = true; // default
o.RestartAttempts = 3; // default
o.RestartRetryDelay = TimeSpan.FromSeconds(5); // default, doubling
o.MaxRestartRetryDelay = TimeSpan.FromSeconds(30); // default
});
});

Needs a Shiny host — UseShiny() in MauiProgram — because that is what delivers the platform’s lifecycle callbacks. On a non-mobile target framework it registers nothing and does nothing, so shared code can call it unconditionally.

The package is .Mobile rather than .Maui because none of this is MAUI: it is built on Shiny.Core’s lifecycle, and an iOS or Android head with no MAUI in it gets the same behaviour. MAUI is simply where most of it gets used.

This is the part of running a server in a MAUI app that nothing else can do for you. A server object does not know the app was backgrounded, and a socket does not know the phone left the Wi-Fi network it was bound to. Both are ordinary events on a device and neither exists on a machine in a rack.

Mode Android Apple
Stop (default) Stops on background, starts on resume Same
KeepAlive Starts a foreground service and keeps serving Keeps running until the OS suspends it (seconds), then restarts on resume
Leave Nothing — you manage it Nothing

Stop is the honest default. On iOS the process is suspended within seconds of backgrounding and the listener stops answering whatever you do, so a server that is left “running” is one that looks fine in your code and refuses connections in reality. Stopping it makes the state visible: server.IsRunning is false, the UI can say so, and clients get a refused connection instead of a hang. A server the user switched off stays off — AlwaysStartOnForeground opts into the other behaviour.

KeepAlive on Android starts a foreground service with an ongoing notification, which is the only supported way to hold a socket open with the app in the background. The notification is the deal Android offers; NotificationTitle and NotificationMessage are what the user reads all day.

The service follows the server, not just the app’s background and foreground transitions. Stop the server while the app is in the background and the service stops with it, so the notification goes away rather than sitting there claiming to serve something that is no longer running; start the server while backgrounded and the service comes up, so Android does not reclaim the process a few minutes later and take the listener with it. Both cases are ordinary in an app whose server is a toggle — a notification action, a scheduled task, a rebind that failed — and neither is visible from the lifecycle transition alone.

Only the settled states move it. A bind that fails goes Starting then Stopped, which would otherwise flash a notification for a server that never came up, and a stop waits in Stopping for in-flight requests — precisely when the process still needs holding up.

There is no foreground service on iOS and there is not going to be one, so KeepAlive there means the other half of the promise: the server is left running as the app goes away — a few seconds of background execution is sometimes exactly enough to finish the request in flight — and it is restarted when the app comes back.

That restart is the part worth having. The suspension takes the socket, but nothing on the platform tells the HttpServer object about it, so it goes on reporting IsRunning as true. An app that checked IsRunning on resume would find nothing wrong and serve nothing, and the user would be looking at a screen that says the server is on while the other device cannot connect — with a toggle that cannot fix it, because the toggle is already in the position it should be in. Calling StartAsync() yourself does not help either: it is idempotent, so it agrees with the stale state and returns having done nothing. RestartAsync() is what fixes it, and this is what calls it.

It only restores a server that was running when the app was backgrounded. Stop the server while the app is in the background and it stays stopped on resume, the same way Android’s notification goes away. If you want the server on at every resume whatever the user did, that is AlwaysStartOnForeground — a different and more opinionated promise, since it overrides a toggle the user deliberately switched off.

So on Apple, Stop and KeepAlive end up in the same place — a server that was on before is on again — and differ in how they get there. Stop stops it on the way out, which makes IsRunning honest the whole time and lets the UI say “not serving” while the app is in the background. KeepAlive leaves it up for the seconds the platform grants, and pays for that with an IsRunning that is stale until the resume repairs it.

A phone’s addresses change as a matter of course — a different Wi-Fi network, a hotspot, cellular taking over. A listener bound to the address it had at breakfast is the single most common way an embedded server “stops working for no reason”.

The lifecycle package rebinds on connectivity changes. Without it (or without Shiny.Core), the core server does the same from NetworkChange:

options.RebindOnNetworkChange = true;
options.NetworkChangeDebounce = TimeSpan.FromSeconds(2); // one transition raises several events

Either way server.NetworkAddressesChanged fires afterwards, so a QR code, a “reachable at” line or an mDNS advertisement can be refreshed. Binding IPAddress.Any survives an address change on its own; binding a specific address does not.

A rebind is the riskiest thing this package does. It unbinds a listener that was working and then binds a new one, and the moment it runs is the worst moment to ask for a socket: the interface the new route runs over may not be up yet, and the port the old listener held may still be sitting in TIME_WAIT. A single attempt that loses that race leaves the server stopped, the app still showing a toggle that is already switched on, and nothing scheduled to try again — which is what “the server shut down randomly” turns out to be nearly every time it is reported.

So every start and restart this package drives is retried on a bounded backoff — three attempts, five seconds apart and doubling to thirty:

Property Default
RestartAttempts 3 1 restores the old single-attempt behaviour
RestartRetryDelay 5 seconds Doubles per attempt
MaxRestartRetryDelay 30 seconds Ceiling for the doubling

If it still will not come up, the last line is logged at Error with the exception that refused the bind attached. That level is deliberate rather than cosmetic: the Microsoft.Extensions.Logging bridges crash reporters ship — Sentry’s among them — file an event at Error and leave only a breadcrumb at Warning, so a warning here is a server that stopped and told nobody.

This sits outside the core’s own StartRetryAttempts, which retries the bind within a single RestartAsync, so the two multiply — which is why the count here is small. It is not redundant with it:

  • The core never retries a start the caller asked for, on the grounds that the caller is a button and should be told. On this path the caller is a lifecycle callback with nobody to tell, and that is exactly the case that used to end in silence.
  • It covers a restart that failed before it ever reached the bind — an unbind that threw.
  • It keeps trying past the core’s window, which is spent in about fifteen seconds, while a phone changing networks indoors is often not done in fifteen seconds.

A newer change supersedes the one still retrying rather than queueing behind it: two connectivity changes a second apart are one event as far as the listener is concerned, and the later one knows more about the network. A stop the app asked for cancels the retry outright, so a rebind cannot bring a listener back up for an app that has just decided it does not want one.

When the foreground service does not start

Section titled “When the foreground service does not start”

Host.Platform.StartService posts an intent and returns; the service comes up — or is refused — on the main looper afterwards. Android refuses it outright when the app is not entitled to a foreground service at that moment: a background start without an exemption on API 31+, a missing FOREGROUND_SERVICE_DATA_SYNC, a revoked notification permission. The refusal is thrown inside the service, where the caller cannot see it, and unchecked the first anyone knows is the process being reclaimed some minutes later with the listener inside it.

The lifecycle task now checks, five seconds after asking, that the service actually came up, and logs at Error when it did not — naming the manifest entries to look at. The service logs at Error on the other side of it too: if Android stops it while the server is still listening (the user swiped the task away, the notification permission was revoked), that is the moment the process stopped being held up, and it is written down rather than left to be inferred from a listener that dies minutes later. Nothing here tries to restart a service the OS just stopped — that is fighting the user — but the log line exists.

Each of these fails silently rather than with an error message, which is why they are worth listing.

<!-- Platforms/iOS/Info.plist -->
<key>NSLocalNetworkUsageDescription</key>
<string>This app serves a page to other devices on your network.</string>

iOS 14+ gates anything touching the local network behind a permission prompt, and that includes serving on it. Without the key the app is denied without ever being asked.

<!-- Platforms/MacCatalyst/Entitlements.plist -->
<key>com.apple.security.network.server</key>
<true/>

Mac Catalyst runs sandboxed and the sandbox grants outgoing connections only. Without this the bind is refused and the server simply never appears.

<uses-permission android:name="android.permission.INTERNET" />
<!-- BackgroundServerMode.KeepAlive only -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

INTERNET is already in the MAUI template; the rest are needed only for background serving. POST_NOTIFICATIONS is a runtime permission from API 33 — without it the foreground service runs and its notification does not appear.

var report = LocalNetworkAccess.Check();
if (!report.CanServe)
logger.LogWarning("{Report}", report);

Shiny.Net.HttpServer.Mobile reads the bundle or manifest and says which of the entries above are missing. It exists because of how these fail: a missing NSLocalNetworkUsageDescription does not raise an exception with that name in it — the listener binds, the other device cannot reach it, and there is nothing in the log. Neither failure is discoverable from the failure, and both are discoverable from the bundle.

It does not prompt for anything and cannot tell you whether the user has already granted local network access — no platform exposes that. What it tells you is whether the app was built in a way that makes granting it possible.

Binding a port solves being reachable. It does not solve being found — the address is assigned by whatever network the device joined and changes when it moves. See Discovery for advertising the server over mDNS and finding the ones other devices are advertising, which is what removes “type this IP address” from the app’s first-run experience.

Plain HTTP on the local network is the pragmatic default, because a self-signed certificate has to be installed and trusted per device before a browser will accept it — see TLS & Certificates. Pair it with:

  • iOS: NSAllowsLocalNetworking under NSAppTransportSecurity, for the app’s own HttpClient talking to another device.
  • Android: a network_security_config entry permitting cleartext to the addresses involved.

If the connection leaves the network, terminate TLS at the tunnel with a real certificate and let the server speak cleartext behind it.

A packaged app has no wwwroot on disk, so the assets travel inside the assembly:

<ItemGroup>
<EmbeddedResource Include="wwwroot\**" />
</ItemGroup>
server.UseEmbeddedFiles(typeof(MauiProgram).Assembly, "MyApp.wwwroot");

A whole Blazor WebAssembly app works the same way.

AddBasic<TValidator> is the shape that fits an app whose credentials are editable on a settings screen: the server asks the validator on every request rather than copying a list at startup, so a change takes effect immediately with nothing restarted.

builder.AddAuthentication().AddBasic<CredentialStore>(o => o.Realm = "My device");

Keep the password in SecureStorage — the keychain on Apple platforms, the encrypted preference store on Android — and generate one on first run. A well-known default on something reachable from the internet is the same as no password.

iOS has no JIT, so a MAUI app on it is trimmed by definition and reflection-based serialization is not an option. That is the constraint the whole library is built around, and there is exactly one thing you have to bring:

[JsonSerializable(typeof(DeviceSummary))]
[JsonSerializable(typeof(Note))]
public partial class ApiJsonContext : JsonSerializerContext;
JsonTypeInfoRegistry.Register(ApiJsonContext.Default);

The endpoint generator emits that registration for you and warns (SWS006) about any type it does not cover. Register it by hand only when you are not using the generator.

Two packages need a word here:

  • Shiny.Net.HttpServer.Ssh is pure managed code and runs on iOS and Android — which is why quick tunnels are the tunnelling story for a phone.
  • Shiny.Net.HttpServer.AzureRelay is deliberately not AOT-clean, because its SDK drags in Azure.Identity, MSAL and IdentityModel.
builder.AddQuickTunnel(); // pinggy: no account, nothing installed
public sealed class ShareViewModel
{
public ShareViewModel(QuickTunnel tunnel)
{
tunnel.PropertyChanged += (_, _) => MainThread.BeginInvokeOnMainThread(() =>
{
this.Url = tunnel.PublicUrl;
this.Status = tunnel.State.ToString();
});
}
}

Three things about this that are not decoration:

  • QuickTunnel raises its changes on a background thread. MAUI will not marshal them for you.
  • A free tunnel assigns a different address on every reconnect, and a phone reconnects whenever it changes network. Bind to PublicUrl; do not read it once. When the connection drops it goes null and the state reads Reconnecting — showing nothing beats showing a link that no longer works.
  • StartAsync can return null, and it takes a cancellation token. Opening a tunnel talks to a machine on the other side of the internet; it can sit there for seconds and it can come back with no address at all (State goes Failed, LastError says why). Do not gate every control on a single IsBusy flag while you wait — leave the user a way to cancel, or the screen locks itself out of its own waiting state.

The MCP SDK’s own HTTP transport is an ASP.NET Core package, so this is the part that genuinely cannot be done any other way:

builder.Services
.AddMcpServer(o => o.ServerInfo = new Implementation { Name = "my-device", Version = "1.0.0" })
.WithTools<DeviceTools>(ApiJsonContext.Default.Options)
.WithHttpTransport(o => o.MaxSessions = 8);
// in configureServer:
server.MapMcp();

See Model Context Protocol — including why the JsonSerializerContext is passed to WithTools rather than left to reflection.

samples/Sample.Maui in the repository is all of the above: an embedded page, a small JSON API, a file browser and a WebDAV mount over the app’s own storage, an MCP server, and a public URL — everything but /ping behind a Basic password that is editable in the app. It is built for Android, iOS and Mac Catalyst.

The Server tab prints the file browser and WebDAV addresses next to the MCP one — for the mount, both the LAN one and the tunnelled one. The file browser link is the LAN address: it answers with the contents of the device’s storage, and the tunnel is cleartext HTTP through a shared host. Paste either into Finder’s Go → Connect to Server or Explorer’s Map network drive, sign in with the same account, and the phone’s storage is a drive. Every address on that tab is also a link: tapping one opens it in the system browser, so the WebDAV listing can be walked on the phone and handed to a Mac by Handoff. The account is printed on the same tab for the prompt that follows.

It has two tabs — the Server one carries the addresses and the account that guards them — and the other is the part worth stealing. A ~40-line IHttpMiddleware sits at the front of the pipeline — ahead of authentication, so rejected requests are recorded too — and copies each exchange out of the pooled HttpContext: timestamp, method and target, protocol, status, duration, peer address, whether it arrived through the tunnel, the authenticated user, and every header in both directions. Tapping one shows the whole exchange. It is the fastest way to see what a client is actually sending when an endpoint is not behaving.