It’s been a long road, but Shiny Client v4 is here. This is a major release that brings Windows support, moves to .NET 10, and packs in a significant number of fixes and enhancements
across almost every module. Let’s dig in.
The headline feature of v4 — BluetoothLE, BLE Hosting, HTTP Transfers, and Locations all now work on Windows. Background support isn’t available yet on the Windows platform, but
foreground scenarios are fully supported. This opens up a whole new set of use cases for desktop and kiosk applications built with .NET MAUI.
HTTP Remote Configuration has been moved directly into Shiny.Extensions.Configuration. No more separate package — remote config is now a first-class citizen in the configuration stack.
Locations got a lot of love in this release, particularly on iOS where we’ve adopted the newer Apple APIs:
iOS 18+ now uses CLMonitor for GPS — the modern replacement for the legacy location APIs
New geofence registration mechanics for iOS 17+ using the new CLMonitor API
Geofence Manager RequestState now works correctly on the new CLMonitor API
GPS background permission on iOS is now requested immediately instead of waiting
GpsDelegate now has a boolean to detect if the device is stationary — useful for battery optimization and movement detection
GpsDelegate batch fix — the base calculations could receive a batch and trigger multiple calculations. This is now a synchronized operation
Android background location permission — no longer requests ACCESS_BACKGROUND_LOCATION unless realtime GPS with less than API 31 or standard background tracking is being used
HTTP Transfers received some of the most impactful enhancements in v4:
New transfer types — Transfers can now be UploadMultipart, UploadRaw (body is raw bytes), or Download. The UploadRaw type is critical for sending directly to services like Azure Blob Storage
Azure Blob Storage helper — AzureBlobStorageRequest.CreateForAzureBlobStorage static helper method makes it dead simple to queue uploads to Azure Blob Storage
New HttpTransferDelegate — allows you to set retries and detect denied authorization, enabling you to refresh your token and issue a new request
Thread-safe HttpTransferMonitor — now uses a thread-safe BindingList
Android file validation — uploads now verify the file exists before queuing, and download directories are checked before queuing
iOS special character fix — filenames with special characters are now sent properly, along with improved form data upload
v2.0.0 is now available. This release focuses on flexibility — you can now map document types to dedicated SQLite tables, use custom Id properties, diff objects against stored documents, batch insert collections efficiently, customize the default table name, and use the core library without any dependency injection framework.
The core Shiny.SqliteDocumentDb package no longer depends on Microsoft.Extensions.DependencyInjection.Abstractions. If you use AddSqliteDocumentStore(), add the new package. If you instantiate SqliteDocumentStore directly, no changes are needed.
}.MapTypeToTable<User>() // auto-derives table name → "User"
.MapTypeToTable<Order>("orders") // explicit table name
);
MapTypeToTable<T>() — auto-derives the table name from the type using the configured TypeNameResolution
MapTypeToTable<T>(string tableName) — maps to an explicit table name
Fluent API — calls chain for concise configuration
Duplicate protection — mapping two types to the same table throws ArgumentException
AOT-safe — type names are resolved at registration time, not at runtime
Tables are lazily created on first use with the same schema (Id, TypeName, Data, CreatedAt, UpdatedAt) and composite primary key. This works seamlessly with all store operations including transactions, the fluent query builder, projections, indexes, and streaming.
Types mapped to a dedicated table can use an alternate property as the document Id instead of the default Id. The property must be Guid, int, long, or string — the same types supported for the standard Id property.
Auto-generation rules still apply — Guid and numeric Ids are auto-generated when default, and the value is written back to the mapped property after insert. Custom Id remapping is only available through MapTypeToTable, keeping the shared table convention simple.
Compare a modified object against the stored document and get an RFC 6902 JsonPatchDocument<T> describing the differences. Returns null if the document doesn’t exist. Powered by SystemTextJsonPatch.
varproposed=newOrder
{
Id ="ord-1", CustomerName ="Alice", Status ="Delivered",
ShippingAddress =new() { City ="Seattle", State ="WA" },
// Apply the patch to any instance of the same type
varcurrent=await store.Get<Order>("ord-1");
patch!.ApplyTo(current!);
The diff is deep — nested objects produce individual property-level operations (e.g. /shippingAddress/city), while arrays and collections are replaced as a whole. Works with table-per-type, custom Id, and inside transactions.
BatchInsert inserts multiple documents in a single transaction with prepared command reuse for optimal performance. Returns the count inserted. If any document fails (e.g. duplicate Id), the entire batch is rolled back. Auto-generates IDs for Guid, int, and long Id types.
The AddSqliteDocumentStore() API is unchanged — just a different package.
If you instantiate directly, no changes required. The default behavior is identical to v1 — all documents go to a table called "documents".
Table-per-type and custom Id are opt-in. Existing databases continue to work without any changes. You can incrementally adopt table mapping for specific types while keeping everything else in the shared table.
Here’s something that shouldn’t be hard but is: accessing the music library on a user’s device from .NET MAUI.
On Android, you need MediaStore.Audio.Media, a ContentResolver, cursor iteration, and different permission models depending on whether you’re targeting API 33+ or older. On iOS, you need MPMediaQuery, MPMediaItem, AVAudioPlayer, and an NSAppleMusicUsageDescription entry or the app crashes on launch. There’s no MAUI abstraction. No popular NuGet package. You write platform-specific code from scratch every time.
So I built Shiny.Music — a clean, DI-first API that gives you permission management, metadata queries, playback controls, and file export across both platforms.
The library handles this automatically. RequestPermissionAsync() prompts the user with the correct platform permission. CheckPermissionAsync() checks the current status without prompting.
PlaybackStatestate= player.State; // Stopped, Playing, or Paused
MusicMetadata? current= player.CurrentTrack;
TimeSpanposition= player.Position;
TimeSpanduration= player.Duration;
player.StateChanged += (sender, args) =>
{
// React to state transitions
};
player.PlaybackCompleted += (sender, args) =>
{
// Track finished — load next?
};
On Android, playback uses Android.Media.MediaPlayer. On iOS, it uses AVAudioPlayer via AVFoundation. Both are properly managed — resources are released on stop and disposal.
On iOS, Apple Music subscription tracks (DRM-protected) cannot be played or copied through this API. For these tracks:
ContentUri will be an empty string
CopyTrackAsync returns false
PlayAsync throws InvalidOperationException
Only locally synced or purchased (non-DRM) tracks work. The metadata (title, artist, album, duration) is still available for all tracks — you just can’t access the audio data for DRM-protected ones.
On Android, all locally stored music files work without restrictions via ContentResolver.
This is an OS-level limitation, not a library limitation. iOS simply does not expose the audio asset URL for DRM-protected media items.
If you’ve ever tried to do geospatial work on .NET MAUI, you know the options aren’t great. SpatiaLite requires native binaries per platform. NetTopologySuite is a full-featured geometry library — great on the server, but heavy on mobile and hostile to AOT. And the built-in platform geofencing? iOS gives you 20 circular regions. Android gives you 60. That’s it.
I needed something different — a spatial database that works everywhere .NET runs, with zero native dependencies, and a geofencing system that can monitor thousands of polygon regions from a real geographic database. So I built Shiny.Spatial.
Shiny.Spatial — a spatial database engine that stores geometry in SQLite using R*Tree virtual tables, with all spatial algorithms implemented in pure C#. No SpatiaLite, no NetTopologySuite, no native binaries.
Shiny.Spatial.Geofencing — a GPS-driven geofence monitor that watches spatial database tables for region entry/exit instead of registering individual fences with the OS.
The spatial database targets netstandard2.0 and net10.0, so it runs on MAUI, Blazor, console apps, servers — anywhere SQLite runs. The geofencing package targets iOS and Android via MAUI.
Pass 1 — R*Tree bounding box filter (SQL, O(log n)). SQLite’s R*Tree virtual table eliminates most candidates based on bounding box overlap. This runs entirely in SQL and is extremely fast.
Pass 2 — C# geometry refinement. Surviving candidates are tested with exact geometric predicates — point-in-polygon (ray-casting), segment intersection (cross-product), Haversine distance — in pure C#.
This two-pass approach is the same strategy PostGIS and SpatiaLite use internally. The difference is that both passes here require zero native extensions.
// What happens under the hood for an Intersecting query:
// Pass 1: SELECT * FROM cities_rtree WHERE min_x <= @maxX AND max_x >= @minX ...
Each table gets a single R*Tree virtual table with auxiliary columns for WKB-encoded geometry and user-defined properties. No separate tables, no JOINs.
Coordinates use double X (longitude) and double Y (latitude). Polygon supports interior rings (holes) — useful for real geographic boundaries like city limits with excluded areas.
// A simple polygon — Colorado's approximate boundary
Property filters run in SQL (Pass 1), spatial filters run as C# refinement (Pass 2), and distance ordering plus limit/offset are applied after refinement. The query builder composes them automatically.
Geometry is stored as WKB (Well-Known Binary), the same binary format used by PostGIS, SpatiaLite, and every major GIS tool. The built-in WkbReader and WkbWriter handle all seven geometry types.
If your app needs to know when a user enters Denver, you register a circular region around Denver’s center. But Denver isn’t a circle — it’s an irregular polygon. And if your app needs to track entry/exit for 200 cities, you’re out of luck. You’d need 200 regions, but the OS caps you at 20 or 60.
That’s it. The library now monitors both the states and cities tables. When the user crosses a state boundary, you get an exit + enter event. When they enter a city, you get a separate enter event. The two layers are tracked independently.
GetCurrent() takes a one-shot GPS reading and tells you which region the device is currently in for each monitored table — useful for initial state on app launch.
The tradeoff is coverage. SpatiaLite and NTS support hundreds of spatial operations — buffer, union, difference, convex hull, spatial joins. Shiny.Spatial covers the operations that matter for mobile apps: intersects, contains, within-distance, nearest-to. If you need computational geometry operations, use NTS on the server and ship the results as a spatial database to the device.