Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!
HTTP Server Releases
1.0.5 - August 29, 2026
Section titled “1.0.5 - August 29, 2026”Feature
ZipFileSource serves static files straight out of a zip — on disk, or embedded in the assembly. The packaged case has had EmbeddedFileSource for a while, and it is the wrong shape once the content is a publish output rather than a handful of files: a published Blazor app is a few thousand files, which is a few thousand manifest entries whose paths have been flattened into dotted resource names that then have to be guessed back apart — site.min.css is genuinely ambiguous — and every asset is inflated into the binary. new ZipFileSource(typeof(App).Assembly, "MyApp.wwwroot.zip") is one resource, the paths survive intact, and the content stays compressed. new ZipFileSource("./site.zip") does the same from disk, and a second argument names a directory inside the archive for a zip made with its parent folder. The archive is never held open: the entry index is read once at construction and each response opens its own reader, so one source serves any number of requests concurrently — and reopening an embedded archive costs nothing, because the resource stream is a window onto the already-mapped assembly image rather than a copy of it. ETags come from the entry’s CRC, so they follow the content rather than a build time. See serving from a zip.Enhancement
Precompressed sidecars are no longer a
PhysicalFileSource privilege. The middleware asked for a .br/.gz sidecar by testing the source for that one concrete type, so any other source — including a composite with a directory in front of it — silently served the uncompressed original however the content had been published. Sidecar lookup is now the new IPrecompressedFileSource, implemented by PhysicalFileSource, ZipFileSource and CompositeFileSource (which passes the question through to whichever source answers), and available to your own. A source that has no notion of sidecars is asked for the plain file exactly as before, so nothing changes for EmbeddedFileSource.1.0.3 - August 27, 2026
Section titled “1.0.3 - August 27, 2026”Fix
A listener that died underneath a running server used to say nothing at all. When the listening socket went away without the server being told - the OS reclaimed it, an interface was torn down mid-accept, a platform suspended the process and took the descriptor with it -
AcceptAsync returned null and the accept loop simply returned. No log, no state change, and a server that went on reporting Running forever with nothing behind it. From the outside that is the worst failure available: a toggle that reads “on”, a port that refuses every connection, and no way for the user to fix it by switching it off and on because it is already in the position it should be in. The accept loop now ends quietly only for a stop. Anything else is reported at error level and drives a real transition carrying HttpServerStateReason.ListenerFaulted with the cause attached, and by default the server rebinds - HttpServerOptions.RecoverFromListenerFaults, on out of the box.Fix
An exception out of
AcceptAsync no longer ends the accept loop. A transient SocketException - a client that vanished mid-accept, descriptor exhaustion, an interface going down - escaped the loop and faulted the accept-loop task, which nothing observed until TaskScheduler.UnobservedTaskException surfaced it on the finalizer thread, if ever. Transient failures are now retried with a bounded backoff (AcceptRetryAttempts, AcceptRetryDelay, AcceptRetryMaxDelay), and the counter resets on every connection accepted, so a flaky minute costs nothing. Failures that keep coming are treated as a dead listener. The loop counts consecutive failures rather than classifying error codes: transient-versus-fatal is not decidable from a socket error across Android, iOS and desktop, and anything that has not cleared in a few attempts is fatal whatever its code said.Fix
The start half of
RestartAsync retries. The stop would succeed and the start would fail on a bind refused because the network was half up or the old port was still in TIME_WAIT, leaving the server stopped for good - and both in-tree callers, the network-change rebind and the MAUI lifecycle task, only logged it. A restart is the one operation where failing leaves the server worse off than not having tried, so its start half now retries with backoff (StartRetryAttempts, StartRetryDelay, StartRetryMaxDelay, on by default). If the retries are spent it still throws and the transition to Stopped carries BindFailed with the exception. A start the app asked for is deliberately never retried: StartAsync reports straight back to its caller, which is louder than a toggle that looks stuck for fifteen seconds.Fix
A rebind after a network change no longer gives up after one attempt. It logged the failure and left the server stopped, on the theory that the next address change would try again - but on a phone that has settled onto cellular there may not be a next change, and the server was down until something unrelated happened along. It now uses the same bounded retry as any other server-initiated start, and the transitions carry
NetworkChanged so a subscriber can tell a rebind from a shutdown.Fix
A
StateChanged handler that throws can no longer take the server down. Handlers were invoked from inside StartCoreAsync’s own try, so an exception out of a UI binding was caught by the start’s catch, which unbound the listeners and reported a failure for a start that had already succeeded - the server went down because something drawing a button threw. Handlers now run isolated and are logged if they throw. They are also invoked one subscriber at a time rather than through the multicast delegate, which stops at the first one that throws: a broken UI binding used to silently stop the mDNS advertiser and the background-execution task from ever hearing that the server had moved.Feature
HttpServer.StateTransitioned says why the server changed state. StateChanged reports Stopped whether the app asked for it or the server fell over, which is enough to draw a toggle and nothing else. The new event carries an HttpServerStateChange - the state, an HttpServerStateReason (Requested, Restarting, NetworkChanged, BindFailed, ListenerFaulted, Disposed) and the Exception behind it when there was one. HttpServer.LastStateChange holds the same record for code that arrived late: a crash reporter assembling context, a diagnostics screen opened after the fact, a background task that woke to find the server down. StateChanged is unchanged and still raised. Restarting is worth handling even when nothing is wrong - it appears on the stop half of a restart, so a subscriber that tears down a notification or an advertisement on every Stopped can tell “down for a moment” from “down”.Fix
Every start and restart the MAUI lifecycle drives is now retried, and giving up is an error rather than a warning. A rebind on a connectivity change unbinds a listener that was working and then binds a new one, at the exact moment the interface may not be routable yet and the old port may still be in
TIME_WAIT. One attempt that lost that race left the server stopped, the app still showing a toggle 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. The connectivity rebind, the Apple resume restart and the foreground start all now retry on a bounded backoff (RestartAttempts, RestartRetryDelay, MaxRestartRetryDelay, three attempts five seconds apart by default), and when the attempts are spent the last line is logged at Error with the bind exception attached. The level is the point: the Microsoft.Extensions.Logging bridges crash reporters ship file an event at Error and leave only a breadcrumb at Warning, so the previous LogWarning was a server that stopped and told nobody. This sits outside the core’s own StartRetryAttempts and is not redundant with it - the core deliberately never retries a start the caller asked for, and on this path the caller is a lifecycle callback with nobody to tell. A newer connectivity change supersedes the retry still in flight, and a stop the app asked for cancels it, so a rebind cannot bring a listener up for an app that has just decided it does not want one.Fix
A foreground service that never started is now reported.
StartService posts an intent and returns; the service comes up - or is refused - on the main looper afterwards, and Android refuses it outright when the app is not entitled to one 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, so the first anyone knew was the process being reclaimed minutes later with the listener inside it. BackgroundServerMode.KeepAlive now checks five seconds after asking and logs at Error when the service is not up, naming the manifest entries to look at. The reverse case is logged too: if Android stops the service while the server is still listening - the user swiped the task away, the 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. A failure to start or stop the service around a server transition also moved from Warning to Error, for the same reason: with nothing holding the process up, the listener has minutes to live.Fix
The mDNS advertisement no longer blinks out and back on every restart, and a refused registration is retried. Two fixes to the same failure - an app that is running and cannot be found. The advertiser now subscribes to
StateTransitioned rather than StateChanged, so a Stopped carrying Restarting or NetworkChanged leaves the record standing instead of sending a goodbye and a fresh announcement for a service that never actually went away; peers holding a resolved address keep it. If the start half never lands, the Stopped that follows carries BindFailed and that one does withdraw. Publishing is also retried on a bounded backoff (PublishAttempts, PublishRetryDelay, MaxPublishRetryDelay) - the moment the advertiser publishes is the moment a responder is least likely to answer, and one refusal used to be the end of it - with an Error carrying the exception when the attempts are spent. A server that reports Running with no listen URL is logged rather than silently publishing nothing, and updates are now applied in the order the server actually moved in, so a withdrawal pushed off the lifecycle thread can no longer land on top of the publication that followed it.1.0.2 - August 25, 2026
Section titled “1.0.2 - August 25, 2026”Feature
BackgroundServerMode.KeepAlive now restarts the server on resume on iOS and Mac Catalyst. There is no foreground service on Apple platforms, so KeepAlive there left the server running as the app went away - useful, because a few seconds of background execution is often exactly enough to finish the request in flight - and then did nothing more. The suspension takes the socket, but nothing on the platform tells the HttpServer object about it, so it went on reporting IsRunning as true: the user came back to a screen that said the server was on while the other device could not connect, and the toggle could not fix it because the toggle was already in the position it should be in. Nor could the app fix it in its own resume handler - StartAsync() is idempotent, so it agreed with the stale state and returned having done nothing. AddHttpServerLifecycle now records whether the server was running when the app was backgrounded and calls RestartAsync() when it comes back, so a server that was serving when the user switched away is serving again by the time they are looking at the app. Only one that was running is restored: stop the server while the app is backgrounded and it stays stopped, the same way the Android notification goes away. AlwaysStartOnForeground is still the separate, more opinionated opt-in for starting it at every resume regardless. Android is unaffected - its foreground service keeps the listener open, so there is nothing to restore.Fix
The Android foreground service now follows the server rather than only the app’s lifecycle transitions. It was decided once, at the moment the app was backgrounded, and never revisited - so it answered “was the server running when the user left?” rather than “is the server running now?”. Those come apart in both directions and both are visible to the user: a server stopped while the app is in the background left the ongoing notification up, holding the process alive and telling the user something was being served when nothing was; a server started while backgrounded got no service at all, so Android reclaimed the process within minutes and the listener died with it. Both are ordinary in an app whose server is a toggle.
BackgroundServerMode.KeepAlive now starts and stops the service on the server’s own Running and Stopped transitions while the app is backgrounded. The transitional states deliberately do nothing: a failed bind goes Starting then Stopped and would otherwise flash a notification for a server that never came up, and a stop waits in Stopping for in-flight requests, which is exactly when the process still needs holding up. Apple platforms are unaffected - there is no foreground service to toggle.1.0.0 - August 24, 2026
Section titled “1.0.0 - August 24, 2026”Initial Release
BREAKING
Every registration in this library now extends
ShinyHttpServerBuilder instead of IServiceCollection, and services.AddHttpServer(...) is now services.AddShinyHttpServer(builder => ...) — the same shape Shiny.Mediator uses. HttpServer.CreateBuilder() returns the same builder, so the standalone and hosted arrangements are configured with identical calls rather than two parallel APIs. Migration is mechanical: builder.Services.AddCors(...) becomes builder.AddCors(...), and a host’s services.AddHttpServer(o => ..., server => ...) becomes services.AddShinyHttpServer(http => { http.Options...; http.Configure(server => ...); }). The one registration left on IServiceCollection is AddHttpServerLocator(), the client half of discovery, which has no server to hang off. Why bother: typing builder. now lists what this server can do, where typing services. lists everything every library in the app has ever registered — and an embedded server’s features are exactly the ones nobody knows to go looking for.Enhancement
HttpServerOptions and HttpServerLimits are registered in the container in both hosting arrangements. Previously the builder registered them and AddHttpServer did not, so a middleware that asked for the limits — request decompression does — silently got the defaults instead of the configured ones inside a host. The builder also registers the HttpServer itself through one factory used by both paths, so an endpoint class that injects the server gets the running instance either way.Feature
mDNS discovery (
Shiny.Net.HttpServer.Discovery) is the other half of hosting on a phone. Binding a port makes a server reachable; it does not make it findable, because the address is assigned by whatever network the device joined and changes when the device moves — which leaves a QR code, someone typing an IP, or this. AddHttpServerAdvertisement() publishes the server on the local link and keeps the advertisement honest: it goes up on the port actually bound (an OS-assigned one included), comes down with a goodbye packet when the server stops rather than expiring on a TTL, moves to the new port across a restart, and re-announces when the device changes network. AddHttpServerLocator() is the other side — FindFirstAsync, FindAllAsync and a WatchAsync that never completes for a UI to bind to — and what it hands back is a BaseAddress ready for an HttpClient rather than a bag of records. Publishing goes through NSNetService and NsdManager, so no multicast entitlement is needed; iOS still needs NSLocalNetworkUsageDescription and every browsed type in NSBonjourServices.Feature
Mobile lifecycle (
Shiny.Net.HttpServer.Mobile) ties the server to the app and to the network, which is the part of running a server in a mobile 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 network it was bound to. AddHttpServerLifecycle() stops on background and starts on resume by default — the honest behaviour on iOS, where the process is suspended within seconds and a listener that is left “running” looks fine in code and refuses connections in reality. BackgroundServerMode.KeepAlive on Android starts a foreground service, the only supported way to hold a socket open in the background. Connectivity changes rebind the listener. LocalNetworkAccess.Check() reads the bundle or manifest and reports the entries that otherwise fail silently — a missing NSLocalNetworkUsageDescription does not raise an exception with that name in it, it just means nobody can reach you. It is built on Shiny.Core rather than on MAUI, so an iOS or Android head with no MAUI in it gets the same behaviour.Feature
The core server now watches for the machine’s addresses changing:
HttpServerOptions.RebindOnNetworkChange restarts the listeners, and HttpServer.NetworkAddressesChanged fires either way so a QR code or an advertisement can be refreshed. Changes are debounced, because one transition raises several events, and the addresses are compared before anything restarts. Off by default — a server on a fixed machine has nothing to gain, and binding IPAddress.Any survives an address change on its own. HttpServer.ActiveConnections and LocalAddresses.Current() are public alongside it.Feature
Health checks (
AddHealthChecks) with MapHealthChecks(), tags to split liveness from readiness, per-check timeouts, and AddServerCheck() — which is worth having on a device for a reason it never is on a server: the process being alive says nothing about whether the listener survived the last time the app was backgrounded. Checks run concurrently, because a readiness probe that waits out four sequential timeouts has failed at its job long before it answers. Healthy and Degraded are 200, Unhealthy is 503, and the report is written with Utf8JsonWriter, so nothing has to declare a JsonSerializerContext for the framework’s own diagnostics type.Feature
W3C access logs (
UseW3CLogging) write one line per request in the extended log file format IIS uses — self-describing through its #Fields directive, so GoAccess, AWStats, Log Parser or a spreadsheet opens it without being told anything. The argument for a plain file over structured telemetry is that an embedded server usually has nowhere to ship telemetry to, and a file someone can pull off the device beats a log nobody collects. Fields are opt-in by flag, including two extensions the format’s x- rule allows — the matched route template and the connection id — and any request header can become a column of its own. The cookie header is deliberately not in the default set. Nothing touches the disk on the request path: lines are queued and drained by a background task, and when the queue is full they are dropped, counted, and the count is written into the file as a #Remark so the gap is visible rather than silent. Files roll by size, the oldest are pruned, and the counter never reuses a number — a recycled one would be deleted as the oldest while it was being written to. IW3CLogWriter is two methods, for sending the lines somewhere other than a file.Feature
Metrics and traces (
UseTelemetry) on the in-box primitives: one Activity per request continuing the caller’s traceparent, and http.server.request.duration, http.server.active_requests and http.server.active_connections named and shaped by the OpenTelemetry HTTP semantic conventions, so a dashboard built for ASP.NET Core reads this server without being told about it. No OpenTelemetry dependency is taken — HttpServerTelemetry.ActivitySourceName and .MeterName are what an exporter subscribes to. The span is renamed to GET /users/{id} once routing has chosen an endpoint; a 4xx stays Unset because it is the caller’s fault; an unrecognised method reports as _OTHER, since a metric attribute the caller chooses is a memory leak in the collector. ContinueIncomingTrace should be turned off on a tunnel, where a caller who picks the trace id can graft spans onto someone else’s trace.Feature
Request timeouts (
UseRequestTimeouts) bound the half of the exchange your own code is responsible for — the server already bounded how long a client may take. Named policies, an inline duration, [RequestTimeout(2000)] / [RequestTimeout("reports")] / [DisableRequestTimeout] on generated endpoints, and .WithRequestTimeout(...) on a raw route. The timeout arrives as cancellation on ctx.RequestAborted, so a handler that ignores its token still runs to completion — it just does so after the client has been answered. A response that has already started is aborted rather than half-finished, which is what tells the client not to trust it. Exempt anything whose job is to stay open: SSE, downloads, upgrades.Feature
Output caching and conditional requests (
UseOutputCache, TryCompleteConditionalAsync). Static files and downloads have answered If-None-Match since day one; a JSON endpoint now can too, and on a device the 304 saves the serialisation as well as the bytes. CheckPreconditions evaluates If-Match, If-None-Match, If-Modified-Since and If-Unmodified-Since in the order RFC 9110 requires, EntityTag.FromContent(...) makes the tag, and SetETag/SetLastModified/SetCacheControl/SetNoStore write the response side. Output caching stores GET and HEAD 200s only, never a response carrying Set-Cookie, never for an authenticated caller unless the policy says so, and passes a streamed response through untouched. The in-memory store is bounded — the usual host is a phone, where a cache that grows until the OS notices is the fastest way to turn a working app into a terminated one.Feature
Request decompression (
UseRequestDecompression) reads brotli, gzip and deflate request bodies — the mirror of response compression, and on a device the more valuable direction, since uplink is the slow, expensive, battery-hungry half of a mobile connection. It has a hard limit on the decompressed size, which is the whole reason it needs a switch: the request body limit only ever bounded the compressed bytes on the wire, and a few hundred kilobytes of gzip expands to gigabytes if it was built to. An unsupported coding is refused with 415 and an Accept-Encoding header saying what would have worked.Feature
Antiforgery (
UseAntiforgery) with signed double-submit tokens on in-box crypto. The check applies to unsafe methods when the request carries cookies and is skipped when it does not — CSRF is an attack on ambient credentials, and a caller holding a bearer token attaches it deliberately where an attacker’s page cannot. [ValidateAntiforgery] and [DisableAntiforgery] override it either way. This matters here more than it looks: this server ships a file browser and a WebDAV file manager, and putting either behind cookie authentication without antiforgery is a delete button any page on the internet can press.Feature
Security headers and an HTTPS redirect (
UseSecurityHeaders, UseHttpsRedirection). nosniff, X-Frame-Options: DENY, Referrer-Policy: no-referrer and Cross-Origin-Resource-Policy: same-origin by default, applied as the response starts so they cover static files and error responses, and never overriding a header a handler set for itself. CSP is opt-in because a wrong one breaks a working page; SecurityHeaderOptions.SelfOnlyContentSecurityPolicy is a starting point for a UI that ships with the app. HSTS is opt-in and should stay off for a LAN or loopback server — a browser remembers it for the whole host, and a phone that serves plain HTTP tomorrow is locked out of itself.Feature
A reverse proxy route (
MapProxy) forwards a route to another server, streaming bodies in both directions with X-Forwarded-* describing the original caller. It belongs in an embedded server because the device is often the only thing that can reach what the caller wants: a phone bridging its own loopback service out through a tunnel, a Pi fronting a printer that speaks HTTP with no authentication, a dev server forwarding /api so the browser sees one origin. An unreachable upstream is a 502 and one that will not answer is a 504. Upgrades are not forwarded.Feature
WebSockets gained the three things a real deployment asks for.
permessage-deflate (RFC 7692) is negotiated when the client offers it, with no context takeover in either direction — each message compresses on its own, so a socket costs no persistent zlib window, which on a phone serving a dozen clients is the difference between a working app and the memory budget. Keepalive pings notice a peer that has gone quiet rather than closed, which is what a phone losing signal, a laptop sleeping and a NAT dropping an idle mapping all look like. And IWebSocketRegistry is the list, the groups and the dead-socket cleanup an app would otherwise hand-roll: BroadcastAsync, SendToGroupAsync, SendToUserAsync, sends running concurrently so one stalled client holds up nobody, and a dead peer dropped rather than thrown.Feature
OAuth discovery for a remote MCP server (
MapMcpProtectedResource) publishes the RFC 9728 protected-resource document and answers a denied request with WWW-Authenticate: Bearer resource_metadata="…". Without it a protected MCP endpoint is one a client can only be pointed at by hand — which is exactly the case for a server on a phone behind a tunnel, where the address is new every time. Both well-known paths are mounted, anonymously and CORS-open, because a browser-based client fetches the document before it holds any credential at all. Leave Resource null to derive it from the request host; set it when the server has a fixed name, since it ends up inside issued tokens.Feature
Agent-backed tunnels (
Shiny.Net.HttpServer.Tunnels) supervise cloudflared, ngrok and Tailscale Funnel for hosts that can start a process, reporting the public URL they produce and killing the whole process tree on the way out. They are a different shape from an ITunnelProvider — a provider is a listener and needs no bound port, an agent forwards to one — which is why these are for desktop, server and the CLI, and why on a phone the answer is still the SSH provider or the relay. A missing binary throws with what to install; an agent that dies during startup fails immediately rather than waiting out the timeout.Feature
An in-memory test harness (
Shiny.Net.HttpServer.Testing): TestHttpServer.Create(...) and server.CreateInMemoryClient() give an HttpClient wired to the server through a pair of pipes — no port to allocate, no listener to bind, nothing left behind when a test fails half way through. Only the socket is replaced, so the request still goes through the real parser, router, middleware and response framing, and the client is HttpClient, so keep-alive, chunked bodies and content negotiation are all exercised as they are over TCP. HTTP/2 by prior knowledge with one flag. Use a real socket for the things that are actually about the socket — TLS, connection limits, framing.Enhancement
The endpoint generator emits metadata for
[RequestTimeout], [DisableRequestTimeout], [OutputCache], [NoOutputCache], [ValidateAntiforgery] and [DisableAntiforgery], exactly as it already did for [Authorize], [EnableCors], [EnableRateLimiting] and [RequireIpFilter] — a method’s attribute replaces the class’s, and a Disable anywhere wins. So the four tiers stay level: everything a raw route can ask for with an extension method, a generated endpoint asks for with an attribute.Enhancement
Http3Options now documents what HTTP/3 here cannot do: HTTP Datagrams (RFC 9297) and WebTransport both need QUIC’s unreliable datagram frames, and System.Net.Quic exposes no API for them at all. Nothing in this library can add one without reimplementing the QUIC layer, so the endpoint speaks reliable HTTP/3 request streams and stops there. WebSockets and SSE remain the streaming answers.Feature
A middleware can now see a request or response body it did not write, which is what a traffic recorder, a request logger or an audit trail needs and what nothing outside the assembly could do before.
HttpRequest.Body is settable, so a middleware can read the body once and hand the handler a rewound copy of it - assigning also discards any BodyReader already handed out, so the two never disagree about where the body starts. On the way out, IResponseBodyControl and HttpResponse.Bind/HttpResponse.BodyControl are public, so a middleware can wrap the control the response is bound to and watch every byte on its way to the wire - the same seam response compression has always used, which means a handler writing to Body, to BodyWriter or through any of the convenience helpers is covered without knowing the wrapper exists. A wrapper must forward StartAsync and CompleteAsync to the control it wrapped, and flush whatever it buffered before the pipeline unwinds: the connection completes its own producer rather than whatever the response ended up bound to.Feature
A browser
GET on a WebDAV collection now serves a file manager rather than a bare list of links: sizes and modification times, a breadcrumb back to the mount root, drag-and-drop upload with a progress bar (a dropped folder is walked and its collections recreated), new folder, rename and delete, and a download button beside each file while the name itself still opens what it points at. Every action is one of the mount’s own verbs — PUT, MKCOL, MOVE with Overwrite: F, DELETE — so the page offers exactly what the options allow and nothing more: a read-only mount renders the listing with no buttons at all, AllowWrite adds uploading and new folders, AllowDelete adds deleting, and renaming needs both. The page is one self-contained response with no scripts, styles or fonts fetched from anywhere, and the listing is rendered server-side, so a browser with no scripting still gets every link. DirectoryBrowsing = false turns the whole thing off as before.Feature
shinyhttpserver now serves the directory over WebDAV, which makes the address two things at once: the file manager above when a browser opens it, and a drive that Finder, Windows Explorer and the GNOME and KDE file managers can mount at the same URL. -m maps onto the mount unchanged — create, update and delete are still told apart before the handler runs, now across MKCOL, COPY and MOVE as well as PUT, with a MOVE judged by where it lands and renaming needing update and delete. --user, --auth-changes-only, --https, --tunnel, --max-upload, --hidden and --prefix all behave as they did.BREAKING
shinyhttpserver mounts WebDAV where it previously mounted the file browser, so a GET on a directory answers with the file manager’s HTML instead of a JSON DirectoryListing. A script that read those listings should ask for PROPFIND with Depth: 1 instead, which answers 207 with a DAV:multistatus document. GET on a file, PUT and DELETE are unchanged, except that a PUT over a file that already exists now answers 204 rather than 200, and a directory is created with MKCOL /path rather than PUT /path/. The file browser itself is untouched — this is only what the tool mounts.Feature
shinyhttpserver --tunnel opens a pinggy.io quick tunnel and shares the public HTTPS address it hands back, so the directory is reachable from a phone that is not on this network — the banner prints it as Tunnel, and the QR code carries it instead of the LAN address. It is the same QuickTunnel the library exposes, so the tunnel feeds connections straight to HttpServer.ServeAsync and needs no inbound port: --tunnel -a localhost shares the directory publicly while binding nothing on the LAN, and because a tunnelled connection counts as encrypted transport, -u works over it without --allow-insecure-auth. Anonymous tunnels stop after 60 minutes; --tunnel-token <token> passes a pinggy access token and implies --tunnel. The tunnel is public and the traffic passes through pinggy.io, which the banner says on startup — loudly when writes are allowed with no --user. A tunnel that will not open is reported and the tool keeps serving on the local network. The tool now carries Shiny.Net.HttpServer.Ssh, and with it SSH.NET.Feature
shinyhttpserver ends its startup banner with a scannable QR code of the address another device can reach, with the same URL spelled out in full underneath it — point a phone at the terminal and the file browser is on the phone. The encoder is part of the tool (byte mode, error correction level M, versions 1 through 10) rather than a package, so the QR code itself adds no dependency to the tool. It is drawn in half-block characters, two module rows to a text row, so it fits an ordinary window, and always black on white so a dark themed terminal does not hand the reader the negative of the code. --no-qr leaves it out, and it is skipped anyway when the output is redirected, the window is too narrow, or the server is only listening on loopback.Feature
shinyhttpserver listens on every interface by default (-a 0.0.0.0) rather than on loopback. Serving a directory that only the machine serving it can reach is not what the tool is for, and it is what makes the QR code worth scanning. -a localhost still keeps it to this machine. Reads remain the only operation allowed until -m says otherwise, and -u on plain HTTP now refuses to start unless paired with --https or --allow-insecure-auth, since the default address is no longer loopback.

