Mobile
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.
Wiring
Section titled “Wiring”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.
Following the app and the network
Section titled “Following the app and the network”dotnet add package Shiny.Net.HttpServer.Mobilebuilder.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.
KeepAlive on iOS — restored on resume
Section titled “KeepAlive on iOS — restored on resume”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.
When the device moves
Section titled “When the device moves”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 eventsEither 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.
When the rebind is refused
Section titled “When the rebind is refused”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.
Platform configuration
Section titled “Platform configuration”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.
Mac Catalyst
Section titled “Mac Catalyst”<!-- 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.
Android
Section titled “Android”<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.
Checking at runtime
Section titled “Checking at runtime”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.
Being found
Section titled “Being found”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.
HTTP or HTTPS?
Section titled “HTTP or HTTPS?”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:
NSAllowsLocalNetworkingunderNSAppTransportSecurity, for the app’s ownHttpClienttalking to another device. - Android: a
network_security_configentry 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.
Serving a UI
Section titled “Serving a UI”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.
Passwords on a device
Section titled “Passwords on a device”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.
Trimming and AOT
Section titled “Trimming and AOT”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.Sshis pure managed code and runs on iOS and Android — which is why quick tunnels are the tunnelling story for a phone.Shiny.Net.HttpServer.AzureRelayis deliberately not AOT-clean, because its SDK drags in Azure.Identity, MSAL and IdentityModel.
Going public from a phone
Section titled “Going public from a phone”builder.AddQuickTunnel(); // pinggy: no account, nothing installedpublic 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:
QuickTunnelraises 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 readsReconnecting— showing nothing beats showing a link that no longer works. StartAsynccan 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 (StategoesFailed,LastErrorsays why). Do not gate every control on a singleIsBusyflag while you wait — leave the user a way to cancel, or the screen locks itself out of its own waiting state.
An MCP server in an app
Section titled “An MCP server in an app”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.
The sample
Section titled “The sample”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.


