# Shiny Client for .NET
<img src="https://github.com/shinyorg/shiny/raw/HEAD/art/old/logo.png" width="100" />
Shiny is a cross-platform framework designed to make working with device services and background processes easy, testable, and consistent while bringing
things like dependency injection & logging in a structured way to your code!
## Features
* Handles all of the cruft like Permissions, main thread traversal, persistent storage and app restarts
* Brings your infrastructure to the background
* Provides logging to ensure you know when your services fail in the background
* Gives a clean & testable API surface for your code
* Native AOT & trim-friendly across all modules
* Cross-platform: iOS, Android, tvOS, Mac Catalyst, macOS, Windows, Linux, and Blazor WebAssembly (where the platform allows)
## Modules
Every package ships under the same version. Blazor WebAssembly support ships inside each package's `net10.0` build - there are no `*.Blazor` packages. Linux add-ons that talk to D-Bus (`*.Linux`) ship next to the packages below so that dependency never reaches a WebAssembly app.
| Area | Modules | Packages |
|------|---------|----------|
| [Foundation](#foundation) | Core - hosting, DI, stores, lifecycle, connectivity & battery | `Shiny.Core`, `Shiny.Hosting.Maui` (iOS, Android, Mac Catalyst, macOS, Windows, Linux GTK), `Shiny.Hosting.Avalonia` (Android, iOS, Mac Catalyst, desktop), `Shiny.Hosting.Uno` + `Shiny.Hosting.Uno.WebAssembly` (Android, iOS, Mac Catalyst, Windows, Skia desktop, WebAssembly), `Shiny.Hosting.Native` |
| [Background Work & Data](#background-work--data) | Background Jobs, HTTP Transfers, Data Sync | `Shiny.Jobs`, `Shiny.Net.Http`, `Shiny.Data.Sync` |
| [Notifications & Live Surfaces](#notifications--live-surfaces) | Local Notifications, Push Notifications, Live Activities | `Shiny.Notifications`, `Shiny.Push`, `Shiny.Push.AzureNotificationHubs`, `Shiny.Mobile.LiveActivities` |
| [Bluetooth](#bluetooth) | BluetoothLE Client, BluetoothLE Hosting, Beacons, Classic Bluetooth (RFCOMM), MCUmgr firmware updates (Zephyr / MCUboot), system accessory pairing sheet (AccessorySetupKit / CompanionDeviceManager) | `Shiny.BluetoothLE`, `Shiny.BluetoothLE.Hosting`, `Shiny.Beacons`, `Shiny.Bluetooth`, `Shiny.BluetoothLE.McuMgr`, `Shiny.BluetoothLE.AccessorySetup` |
| [Networking](#networking) | Network Discovery (mDNS, SSDP, WS-Discovery), Wi-Fi, VPN | `Shiny.Net.Discovery`, `Shiny.Net.Wifi`, `Shiny.Net.Vpn` |
| [Location](#location) | GPS, Geofencing, Region Geofencing | `Shiny.Locations` (or `Shiny.Gps` / `Shiny.Geofencing` individually), `Shiny.Geofencing.Regions`, `Shiny.Geofencing.Regions.DocumentDb` |
| [Personal Data](#personal-data) | Contacts, Calendar, Call Directory (call blocking & caller ID) | `Shiny.Contacts`, `Shiny.Calendar`, `Shiny.Mobile.CallDirectory` |
| [Devices & Peripherals](#devices--peripherals) | Printing, Gamepads, Wearables, Screen Recording, Biometrics, NFC | `Shiny.Printers`, `Shiny.Printing`, `Shiny.Gamepad`, `Shiny.Wearables`, `Shiny.ScreenRecorder`, `Shiny.Biometrics`, `Shiny.Nfc` |
| [Commerce](#commerce) | In-App Purchases - App Store & Google Play, plus the ASP.NET Core backend; App Store - update checks, store links & review prompts; Age Signals - the store-shared age range for app store age laws | `Shiny.Mobile.InAppPurchases`, `Shiny.Mobile.InAppPurchases.Server`, `Shiny.AppStore`, `Shiny.AgeSignals` |
| [Games](#games) | Game Center - achievements, leaderboards & friends over Apple Game Center and Google Play Games | `Shiny.GameCenter` |
| [AI & Assistants](#ai--assistants) | App Functions, [AI Tools](#ai-tools) | `Shiny.AppFunctions`, `Shiny.*.Extensions.AI` |
### Foundation
* **Core** - hosting, DI, key/value stores, object-store binding, lifecycle hooks, connectivity & battery monitoring - level, state, power source and energy saver - (added automatically by the modules that need them, with any implementation you register first kept), and the platform abstractions every Shiny module builds on - `IPlatform` directories and main thread, plus natively read device and app info (version, application identifier, manufacturer, model, OS version, platform, form factor), culture and time-zone change events and `OpenAppSettings()`, with no MAUI dependency, and `IAppStartupInstaller` to put the app in the OS launch-at-login list (Windows Run key, macOS/Mac Catalyst login items, Linux XDG autostart or a systemd user/system unit) ([docs](https://shinylib.net/client/core/))
* **Avalonia hosting** - `Shiny.Hosting.Avalonia` starts Shiny from Avalonia's `AppBuilder` with `UseShiny(...)` - in `Program.Main` on desktop and in `CustomizeAppBuilder` on Android and iOS, where Avalonia runs it from `Application.OnCreate` / `didFinishLaunching`, so the host is up for background launches - and `ShinyAvaloniaMainActivity` forwards the Android activity events (permission results, activity results, notification intents) to Shiny. Sample: `samples/Sample.Avalonia` ([docs](https://shinylib.net/client/core/))
* **Uno Platform hosting** - `Shiny.Hosting.Uno` runs Shiny inside the Uno.Extensions.Hosting container: `.UseShiny(...)` on the host builder registers Shiny next to your own services (view models inject them as usual) and starts it through Uno's `IServiceInitialize` the moment the container is built. Your `App` implements `IShinyUnoApplication` - its static `ConfigureApp` holds what the template does between `CreateBuilder` and `NavigateAsync` - and `OnLaunched` calls `this.GetShinyBuilder(args)` in place of `this.CreateBuilder(args)`, so `NavigateAsync`, Toolkit navigation and the extended splash are unchanged. On Android, `this.StartShiny<App>()` in your `NativeApplication.OnCreate` builds the host before any activity exists - Uno only calls `OnLaunched` once an activity starts, and with the Skia renderer only creates the App then - so jobs, push and geofences work when the OS starts the app in the background; it works with both Uno renderers. `Shiny.Hosting.Uno.WebAssembly` gives the WebAssembly head the `IJSRuntime` Shiny's browser modules use (`.UseShinyWebAssembly()`), with no Blazor. Sample: `samples/Sample.Uno` ([docs](https://shinylib.net/client/core/uno/))
* **Screen info** - `IScreenInfo` in `Shiny.Core` reports the main display (physical size, density, refresh rate, portrait/landscape) with change events, and locks orientation (`SetOrientation` / `ResetOrientation`) on Android, iOS 16+, Windows tablets and Blazor full-screen pages - natively on every platform with no MAUI, registered by every host. Replaces the orientation and screen-size members of the old `IAppSupport`
* **Screen posture (foldables)** - `IScreenPosture` in `Shiny.Core` reports whether a fold or hinge divides the window, where it is (`Fold` in window dp/points), whether it separates content, tabletop / book posture and the hinge status - Jetpack WindowManager on Android foldables, the hinge and reserved-region APIs on iPhone Duo (iOS 27.1+, including the camera housings as `Occlusions` and the hinge angle), and the Device Posture / Viewport Segments APIs in Chromium on Blazor WebAssembly. `Segments(windowSize)` returns the two panes either side of the fold. Registered by every host; elsewhere it reports `ScreenPosture.None`
* **Android activity results** - `AndroidPlatform.StartActivityForResult(intent)` starts an activity from the current one and awaits its `ActivityResult` (role requests, settings screens, consent dialogs) - `StartIntentSenderForResult(intentSender)` does the same for the `IntentSender` system services such as CompanionDeviceManager hand back - and `ActivityResultReceived` raises every result the host forwards. Shiny.Hosting.Maui forwards results automatically, Shiny.Hosting.Native through `ShinyAndroidActivity`, Shiny.Hosting.Avalonia through `ShinyAvaloniaMainActivity` and Uno through the four overrides on your `ApplicationActivity`, so a module never needs a result activity of its own
### Background Work & Data
* **Background Jobs** - periodic background work backed by `BGTaskScheduler` (iOS/tvOS), `WorkManager` (Android), COM-activated background tasks (Windows), and an in-process managed runner for Linux/macOS/Blazor WASM
* **HTTP Transfers** - resumable background uploads/downloads on `NSURLSession` (iOS/tvOS), an `HttpClient`-driven managed loop with Range-based resume (Android, Windows, Linux, macOS, .NET base), and Service Worker Background Sync (Blazor WASM). Pause/resume support - pause stops a transfer without cancelling it (downloads continue from where they left off, uploads restart - except [tus](https://tus.io) resumable uploads, which continue from the server's offset after a pause, network drop or app restart on every platform but Blazor). First-class Azure Blob Storage, AWS S3 (SigV4) and tus (`TusUploadRequest`) request builders included. `AddTransferProgress()` puts progress in front of the user - an iOS Live Activity, or the Android foreground-service notification promoted to an Android 16 live update - from one manager, with no code in your transfer delegate
* **Data Sync** - bidirectional JSON record sync over HTTP with the same platform tiers as HTTP Transfers: outbox + inbox on `NSURLSession` (iOS/tvOS/Mac Catalyst), Foreground Service + HttpClient (Android), `HttpClient` + connectivity loop (Windows/Linux/macOS), and LocalStorage-backed HttpClient (Blazor WASM). Includes batched outbox, tombstones, conflict resolution, retry with exponential backoff, and AOT-safe serialization through `Shiny.Json`
### Notifications & Live Surfaces
* **Local Notifications** - scheduled, repeating, and geofence-triggered notifications on iOS/macOS, Android, Windows, and Linux (`org.freedesktop.Notifications` D-Bus). Linux has no OS scheduler, so scheduled and repeating notifications fire from an in-process timer while the host runs - an `IntervalTrigger.TimeOfDay` is local time and fires today if that time is still ahead. `TrySetBadge` / `TryGetBadge` set the app icon badge on iOS/Mac Catalyst and the Dock tile badge on macOS, and return `false` on Android and Windows, which have no app-wide badge. `ChannelAction.RequiresUnlock` makes an action button ("Open Garage", "Disarm") ask for the device to be unlocked before it runs, instead of running straight from the lock screen - iOS/macOS and Android 12+. `Notification.ImageUri` (an https URL or a local file) shows an image with the notification - an attachment on iOS/macOS, a big picture on Android, the hero image on Windows - downloaded when the notification is sent, so scheduled ones need no network when they fire, and a missing image never stops the notification
* **Push Notifications** - native APNs/FCM, Firebase Cloud Messaging, Azure Notification Hubs, and Blazor (Web Push). tvOS registers and receives silent pushes; a tvOS notification can only change the app icon badge, so there is no tap-through and `OnEntry` never fires there. Azure Notification Hubs covers iOS, Mac Catalyst, macOS, tvOS, Android and Windows. **Push images:** on iOS, `<ShinyPushServiceExtension>true</ShinyPushServiceExtension>` builds a Notification Service Extension into your app during `dotnet build` - no Xcode project, Swift or Info.plist edits, its provisioning profile inferred and signed like the app's - that downloads the image URL in a push sent with `"mutable-content": 1` (`image`, `image-url`, `attachment-url` or Firebase's `fcm_options.image` by default; `ShinyPushImageKeys` changes them) and attaches it. The extension is plain Swift with no .NET runtime, so it fits iOS's ~24 MB extension budget, and `ShinyPushServiceExtensionSource` swaps in your own. On Android, FCM shows a notification message's image itself in the background, and `AndroidPushNotification.SendDefaultAsync(id)` adds it as a big picture in the foreground. The Web Push service worker shows the payload's `image` in Chromium browsers. `Notification.ImageUri` reports the URL on every platform
* **Live Activities** - the persistent, updating status surface both phone platforms grew: iOS/iPadOS Live Activities on the Lock Screen and in the Dynamic Island (ActivityKit 16.2+, through a bundled Swift shim - ActivityKit has no Objective-C interface), and Android 16 Live Updates, where a promoted ongoing notification also renders as a status bar chip and on the always-on display (degrading to an ordinary progress notification below API 36). One typed `LiveActivityContent` - title, body, short status, progress, stale date, plus a free-form data bag your own widget reads - so the two platforms cannot drift in what they say. On iOS, `<ShinyLiveActivityWidget>true</ShinyLiveActivityWidget>` builds the Lock Screen / Dynamic Island widget extension into your app during `dotnet build` - no Xcode project, Swift or Info.plist edits - with its provisioning profile inferred and signed like the app's, and `ShinyLiveActivityWidgetSource` swaps in your own SwiftUI. Both APNs token kinds are surfaced (`OnPushTokenChanged` for a single activity, `OnPushToStartTokenChanged` for starting one with the app closed) and pair with `Shiny.Extensions.Push` server-side. `AddLiveActivities(o => o.ChannelName = ...)` localizes the Android notification channel's name and description, re-applied on every startup so a translation shipped after first launch still reaches the settings screen. **Mobile only, deliberately** - ActivityKit is marked `@available(..., unavailable)` on macOS, Mac Catalyst, tvOS and watchOS in Apple's own SDK, so a Mac only ever mirrors an iPhone's activity; everywhere outside iOS and Android `IsSupported` is false and every call is a safe no-op, so shared view models need no platform checks
### Bluetooth
* **BluetoothLE Client** - scan, connect, GATT, and L2CAP CoC on iOS/tvOS/macOS, Android, Windows, Linux (BlueZ), and Blazor WebAssembly (Web Bluetooth). L2CAP file transfers in both directions - `await peripheral.UploadFile(psm, path, onProgress: ...)` / `DownloadFile(...)` - with the same transfer metrics as HTTP Transfers (percent complete, bytes/sec, ETA), plus Rx `*WithProgress` variants. `channel.AsStream()` hands an open L2CAP channel to anything that takes a `System.IO.Stream`. Against a host that shares one PSM between many transfers, `await peripheral.OpenL2CapTicketChannel(psm, token)` opens the channel, presents the single-use ticket the host issued, and returns the stream once the host accepts it - a refusal throws `L2CapTicketException` naming why. `WriteCharacteristicMessageAsync(...)` / `NotifyCharacteristicMessages(...)` carry messages longer than one GATT operation, split to the MTU with a one-byte header and reassembled on the far side, so a JSON command or a certificate needs no protocol of your own. `IPeripheral.Mtu` reports the usable payload per GATT operation (the negotiated ATT MTU minus the 3-byte ATT header) on every platform - fragment writes to it directly. Writes without response on Apple wait on CoreBluetooth's flow control without ever missing its ready callback, so a full send buffer paces the stream instead of wedging the peripheral's operation queue. That wait runs outside the shared GATT operation queue and gives up after 5 seconds, so a peripheral that stops draining its buffer fails its own writes with a `BleException` while reads, notifications and other writes carry on. A GATT operation that a disconnect fails after its subscriber has already gone - the usual case when a `WhenStatusChanged()`-driven pipeline tears down on the same drop - is dropped instead of surfacing through `TaskScheduler.UnobservedTaskException`, so a cleanly handled disconnect no longer lands in crash telemetry. `ConnectionConfig(AutoConnect: true)` - the default - keeps the link up on every platform, re-establishing it after a peripheral drops out of range or is power-cycled. On iOS/Mac Catalyst/macOS and Android it also survives the user toggling Bluetooth off and on - an adapter power-down runs the same teardown a real disconnect gets, so `WhenStatusChanged()` never lies about a dead link, connects issued while the adapter is off are parked and replayed rather than silently dropped, and a scan started while the link is down no longer evicts the peripheral waiting to reconnect. `Scan()` is parked the same way: on Apple platforms a CoreBluetooth central reports `Unknown` until it finishes powering on, and a scan issued into that window used to be dropped without a word - it now starts as soon as the adapter is up, and resumes by itself after a power cycle. `CancelConnection()` stays final. On iOS/Mac Catalyst the three system alerts CoreBluetooth can raise for a peripheral while the app is out of the foreground ("*accessory* would like to open *app*" on connect, disconnect or every notification) are now `AppleBleConfiguration.NotifyOnConnection` / `NotifyOnDisconnection` / `NotifyOnNotification` - still on by default, and worth turning off in an app that connects from the background deliberately. `bleManager.GetNativeObject()` / `peripheral.GetNativeObject()` (`INativeBleManager` / `INativePeripheral`) hand the platform object behind each one to diagnostics and inspection tools without a per-platform cast - `CBCentralManager`/`CBPeripheral`, `BluetoothManager`/`BluetoothDevice`, `BluetoothAdapter`/`BluetoothLEDevice`, or the BlueZ object path on Linux - and reading them never creates the object (or raises a permission prompt) as a side effect; `bleManager.WhenNativeObjectLoaded()` (the `NativeObjectLoaded` event) emits the manager's object once the app creates it. For experts only, `SetNativeObjectAsync(...)` (or `SetManager` / `SetNative` / `SetAdapterAsync` / `SetConnection` on the platform class) hands Shiny a native manager, adapter or D-Bus connection you created yourself, before Bluetooth is first used - it can break scanning, connections and background restoration if it isn't configured exactly as Shiny expects
* **BluetoothLE Hosting** - GATT server, advertising, iBeacon broadcasting, and L2CAP CoC listeners on iOS/macOS, Android, Windows, and Linux (BlueZ over D-Bus, plus AF_BLUETOOTH sockets for L2CAP). `OpenL2CapFileServer(rootDirectory)` turns a PSM into a file server that connected centrals can push to and pull from, with size limits, per-request authorization, path-traversal protection, and progress callbacks. `L2CapTicketBroker` (`services.AddL2CapTicketBroker()`) opens one listener and shares it between every bulk transfer: `Reserve(label, lifetime, handler)` returns a single-use ticket to hand the central over GATT, and the first channel to present its token gets the handler with the channel as a `Stream` - a wrong, expired or already-claimed token is refused and closed. `[RequestResponseCharacteristic(Framed = true)]` accepts requests and sends replies longer than one GATT operation - fragments are reassembled per central, the handler runs once with the whole message, and the reply is split to the central's MTU - with `IGattCharacteristic.NotifyMessage(...)` and `BleServiceContext.GetMessageReassembler(...)` for doing the same by hand. `AddService(...)` and `StartAdvertising(...)` wait out the peripheral manager's power-on handshake before talking to CoreBluetooth, so calling either at startup completes instead of hanging on a callback the OS never sends. `Notify(...)` on Apple waits out a full transmit queue and retries until CoreBluetooth accepts the value, rather than hanging on a missed ready callback or silently dropping the notification. `Notify(data, cancellationToken, ...centrals)` stops waiting when the token is cancelled or Bluetooth powers off, and the `centrals` you name are honoured on every platform - Apple used to send to every subscriber regardless, and Android sent a plain `Notify(data)` to nobody. Subscribers are tracked whether or not you pass a subscribe hook, so a reply notified to the central that wrote reaches it. A read, write or subscribe handler that throws is answered with a GATT error on Apple and Android, as it already was on Linux, instead of crashing the app from an `async void` callback. On Android `Notify` waits for each central's `onNotificationSent` before sending it the next value, so a burst is paced instead of silently losing everything after the first, and a central that enabled indications now gets indications. `AdvertisementOptions` now carries `ServiceData`, `ManufacturerData`, `IsConnectable` and `IncludeTxPower`, so a peripheral can put an arbitrary payload on the air (Android, Windows and Linux - Apple's `startAdvertising` reads only a local name and service UUIDs, and throws rather than silently dropping the rest). `AdvertiseBeacon(...)` is now correct on every platform: it built the packet from `Guid.ToByteArray()` and `BitConverter.GetBytes()`, both little-endian, and shipped a byte-swapped UUID, major and minor that no receiver could match - and prefixed AltBeacon's `0xBE 0xAC` where iBeacon wants `0x02 0x15`. Windows gained iBeacon broadcasting, which previously threw, and Linux gained advertising outright - `StartAdvertising` and `AdvertiseBeacon` were both unimplemented stubs. BlueZ inverts the model: rather than handing a payload to an API, the app exports an `org.bluez.LEAdvertisement1` D-Bus object that BlueZ then calls *back into the process* to read, so Shiny exports one and registers its path with `LEAdvertisingManager1`. BlueZ's own `Release()` is honoured, so an adapter power-down or a `bluetoothd` restart clears `IsAdvertising` instead of leaving it stuck on. Linux hosts GATT services too - `AddService` used to throw - by exporting the `ObjectManager` / `GattService1` / `GattCharacteristic1` tree BlueZ reads and registering it with `GattManager1`. BlueZ only reports whether *any* central has notifications enabled and fans every value out to all of them, so on Linux `Notify` cannot address a single central
* **Classic Bluetooth** (`Shiny.Bluetooth`, plus `Shiny.Bluetooth.Linux`) - BR/EDR for the devices BLE never replaced: SPP serial adapters, receipt printers, barcode scanners, OBD dongles. Client and server in one `IBluetoothManager` on Android, Windows, macOS (AppKit) and Linux (BlueZ). There is deliberately **no iOS or Mac Catalyst target** - classic Bluetooth there is ExternalAccessory, which only reaches MFi-certified accessories
* **MCUmgr / firmware updates** (`Shiny.BluetoothLE.McuMgr`) - the SMP management protocol spoken by Zephyr RTOS, nRF Connect SDK, Mynewt and NuttX devices that boot through MCUboot, over the SMP GATT service (`8D53DC1D-...`). `peripheral.CreateMcuMgrClient()` returns an `McuMgrClient`, and `await client.UpgradeFirmwareAsync(new FirmwareImage(bytes), options, progress)` runs the whole over-the-air update: it reads image state, skips images the device already runs or holds, uploads with MTU/buffer-sized chunks (resuming an interrupted upload of the same file), marks the image for test, resets, waits for the swap, reconnects and confirms - `FirmwareUpgradeMode` picks `TestAndConfirm` (default), `TestOnly`, `ConfirmOnly` or `UploadOnly`. `FirmwareImage.FromDfuPackageAsync(stream)` reads an nRF Connect SDK `dfu_application.zip`, including multi-image packages for the nRF5340 network core; `McuBootImage.Parse(bytes)` reads an image's version and hash without the device. Beyond firmware, `client.Os` (echo, reset, buffer parameters, app/bootloader info, erase storage), `client.Images`, `client.Files` (upload/download/size/SHA-256/CRC32), `client.Shell` and `client.Stats`, plus `client.SendAsync(...)` for any other or application-defined group. Speaks SMP v2 and falls back to v1 for older firmware. `ISmpTransport` is public for serial/UDP transports or tests. No platform code - it sits on `IPeripheral`, so it works wherever `Shiny.BluetoothLE` or `Shiny.BluetoothLE.Linux` does
* **Accessory Setup** (`Shiny.BluetoothLE.AccessorySetup`) - the system's own pairing sheet for your BLE accessory, with no MFi and no registration: AccessorySetupKit on iOS 18+ (a half-sheet with your product image, optionally only for accessories held next to the phone via `AccessoryRange.Immediate`) and CompanionDeviceManager on Android. `IAccessorySetupManager.ShowPicker(items)` returns the `SetupAccessory` the user chose, or null if they dismissed the sheet or nothing was found; `bleManager.GetPeripheral(accessory)` turns it into an `IPeripheral` with no scan. The choice is the permission - iOS never shows the Bluetooth prompt to an app that only uses picked accessories, and Android scans without location permission (GATT still needs `BLUETOOTH_CONNECT`). Filters (service UUID, service data, company identifier, manufacturer data with masks, name substring, LE pairing) are validated before the picker opens, so a bad one fails with a message naming it. `GetAccessories()` / `Remove()` manage grants, and `IAccessorySetupDelegate` hears about removals in Settings and renames (iOS) and - Android 12+ through `StartObservingPresence` - the accessory coming into or leaving range, with the app woken for it. `AddAccessorySetup()` registers Bluetooth LE (`AddBluetoothLE()`) too. iOS and Android; elsewhere only Bluetooth LE is registered
* **Connections are a `Stream`.** `await device.Connect(BluetoothServiceUuids.SerialPort)` looks the service up over SDP, opens RFCOMM and returns a `BluetoothConnection` whose `Stream` works with `StreamReader`, `PipeReader` or anything else; dispose it to disconnect. `Listen(serviceUuid, serviceName)` publishes an SDP record and hands each incoming connection out of `listener.Accept()`; dispose the listener to withdraw the record. `secure: false` opts into Android's "insecure" sockets and Windows' plain sockets for devices that cannot pair
* **Devices** - `GetPairedDevices()`, `GetDevice(address)` for an address you already know, and `Discover(ct)` - an `IAsyncEnumerable<BluetoothDiscoveryResult>` over a ~12 second inquiry yielding each address once with the RSSI it was heard at. Every one is an `IBluetoothDevice` (address, name, paired state, Class of Device decoded to `MajorDeviceClass`) that you `Pair()` through the OS's own prompt and `Connect(serviceUuid)` to; and `RequestDiscoverable(duration)` on Android (system dialog) and Linux. Windows and macOS give apps no way to become discoverable, so `Capabilities` says so and the call throws `BluetoothNotSupportedException`
* **Backends** - `BluetoothAdapter`/`BluetoothSocket` on Android, `Windows.Devices.Bluetooth.Rfcomm` on Windows, IOBluetooth on macOS (driven through the Objective-C runtime, since the .NET macOS SDK has no IOBluetooth binding) and BlueZ on Linux, where every RFCOMM connection in both directions goes through `ProfileManager1.RegisterProfile` and BlueZ hands the connected socket back as a file descriptor. `AddBluetooth()` registers it on Android/Windows/macOS; on Linux reference `Shiny.Bluetooth.Linux` and call `AddLinuxBluetooth()`
* **Beacons** - iBeacon and Eddystone ranging, background region monitoring, and broadcasting on iOS, Mac Catalyst, macOS, Android, Windows, Linux and Blazor WebAssembly
* **Distance estimates are filtered, not raw.** BLE RSSI swings 10 dBm between consecutive advertisements from a stationary beacon, which a naive calculation turns into metres of jitter. Every sample goes through a windowed filter (20s by default) that discards the top and bottom tenth before averaging, then into a pluggable `IBeaconDistanceEstimator` - `PathLossDistanceEstimator` (`d = 10^((txPower-rssi)/(10n))`, `n` configurable for cluttered rooms) by default, with the Radius Networks/AltBeacon `CurveFitDistanceEstimator` available for parity with the wider Android ecosystem. `Proximity` is derived from the resulting distance against Apple's own boundaries (0.5m / 3m), all of it tunable through `BeaconRangingOptions`
* **iBeacon takes a different route on Apple, because it has to.** CoreBluetooth strips Apple's own iBeacon manufacturer data out of every scan result, so no BLE scan can ever see one on iOS, Mac Catalyst or macOS - ranging and monitoring there go through CoreLocation and cost a *location* permission rather than a Bluetooth one. Everywhere else the same shared managed engine parses the advertisement directly. Monitoring uses `CLMonitor` + `CLBeaconIdentityCondition` on iOS/Mac Catalyst 18+ (the same path `Shiny.Geofencing` takes for geofences, cold-start replay suppression included) and `CLLocationManager` region monitoring below it, where the 20-region-per-app cap is enforced with a clear error instead of iOS silently dropping the excess
* **Eddystone works the same everywhere, Apple included** - it rides in service data under `0xFEAA`, which CoreBluetooth passes through untouched. `IEddystoneScanner` decodes UID (namespace/instance), URL (with the full scheme and top-level-domain compression table), and TLM telemetry - battery volts, temperature, advertisement count and uptime, with "mains powered" and "no sensor fitted" reported as `null` rather than as zero and 128°C. Encrypted TLM is handed back intact for a caller holding the identity key; EID frames surface their raw rotating identifier. Filtering the scan on `0xFEAA` is also what lets it keep running when an iOS app is backgrounded
* **Region monitoring infers exits from silence, and now does it correctly.** BLE has no "left the area" event, so a region that has gone `RegionExitTimeout` (30s by default) without a matching advertisement is treated as exited. The pre-revival module got the entry side wrong in a way that mattered: it seeded the first sighting as *already inside* and then tested for a change, so a region entered from cold never raised an event at all. It also crashed with a duplicate key when a region was re-registered, rejected `Major = 0` as invalid, accepted any manufacturer payload starting `0x02 0x15` regardless of who sent it, and ran its Android foreground service as `location` where Android 14 requires `connectedDevice` for a BLE scan. All fixed, and the state machine is covered by tests against a fake clock
* **Broadcasting** - `StartIBeacon(uuid, major, minor)` works on iOS, Mac Catalyst, macOS, Android, Windows and Linux; `StartEddystoneUid(...)`/`StartEddystoneUrl(...)` on Android, Windows and Linux. Apple cannot broadcast Eddystone at all (no service data in `startAdvertising`) and stops emitting a decodable beacon once the app is backgrounded - both reported as a `PlatformNotSupportedException` naming the actual limit rather than appearing to work
* **Platform notes** - macOS ranges and reads Eddystone but has no beacon region API at all, so its monitoring manager reports `AccessState.NotSupported` through the same channel every other capability uses instead of throwing at startup. Linux does all four through `Shiny.BluetoothLE.Linux` and `Shiny.BluetoothLE.Hosting.Linux`. Blazor reads both formats where the browser exposes `requestLEScan`. tvOS has no `CLBeacon` binding whatsoever, so it has no beacons target
### Networking
* **Network Discovery** - three local-network discovery protocols in one package, each with browse, resolve, and publish
* **mDNS/DNS-SD** (Bonjour/Zeroconf) - backed by `NSNetService` on iOS/tvOS/Mac Catalyst/macOS (so **no** `com.apple.developer.networking.multicast` entitlement is needed), `NsdManager` on Android (no multicast lock), and a dependency-free managed responder on UDP 5353 for Windows, Linux, macOS console, and server .NET. `await foreach (var r in mdns.Browse("_http._tcp", ct))` for live discovery, `BrowseOnce(...)` for a one-shot scan, and `Publish(...)` to advertise your own service with TXT metadata
* **SSDP/UPnP** - find routers, media servers, Sonos, Roku, and smart TVs. `ssdp.SearchAll()` for a one-shot sweep, `Browse(...)` for a live list keyed on UDN with `ssdp:alive`/`byebye` tracking and BOOTID-aware expiry, `GetDescription(...)` to fetch and parse the device description (friendly name, model, icons, service list), and `Publish(...)` to advertise your own root device. Discovery and description only - no SOAP action invocation, no GENA eventing
* **WS-Discovery** - find ONVIF cameras, WSD printers/scanners, and Windows machines. Probe/Resolve/Hello/Bye on both the 2005 (ONVIF/Windows) and 2009 (OASIS) profiles, with correct QName-prefix resolution for `Types`, RFC 3986 segment-prefix scope matching, and `ProbeOnvifCameras(...)` as a shortcut
* **Platform requirements differ by protocol.** mDNS needs no entitlement anywhere. SSDP and WS-Discovery have no OS-level API on any platform, so they use raw multicast: iOS requires the Apple-approved `com.apple.developer.networking.multicast` entitlement, Android needs `CHANGE_WIFI_MULTICAST_STATE` (the multicast lock is acquired for you) plus `ACCESS_LOCAL_NETWORK` from Android 17. Sandboxed hosts still need their usual network permission - `com.apple.security.network.client`/`.server` on Mac Catalyst & macOS, `privateNetworkClientServer` for packaged Windows apps. A missing one throws `DiscoveryPermissionException` naming exactly what to add, rather than silently finding nothing
* **Wi-Fi** - scan for access points, join and leave networks, manage the networks the device has saved, watch the current network's SSID/signal/IP/DNS, and host a hotspot
* **Platform reach is genuinely uneven, and the API says so rather than pretending otherwise.** Every manager publishes a `WifiCapabilities` flags property; anything unavailable throws `WifiNotSupportedException` naming the specific limit (an Apple entitlement, an Android API level that revoked the call, a platform with no such concept). Check the flag to branch, catch the exception as a backstop
* **`IWifiManager`** - `Scan(ct)` returns one `WifiNetwork` per BSSID with SSID, security scheme, dBm + 0-100 signal, frequency, band and channel. `Connect(new WifiConnectionRequest(ssid) { Passphrase = ... })` joins and waits for DHCP rather than returning on association, and with `Remember` left on also persists the network for later (a `WifiNetworkSuggestion` on Android 11+, an ordinary profile elsewhere). `await GetCurrentNetwork(ct)` reports the joined network - SSID, BSSID, security, signal, plus every IP, DNS resolver, gateway and mask - and `Changed` fires with the new `WifiNetworkInfo?` (null when Wi-Fi drops), de-duplicated so the chatty native watchers behind it do not leak through, delivering the current network once on subscribe. Reading the SSID is asynchronous because both phone platforms stopped answering synchronously: iOS 14 replaced `CNCopyCurrentNetworkInfo` with `NEHotspotNetwork.fetchCurrent`, and Android 12 redacts the SSID and BSSID out of every pull-style read no matter what permissions are held, handing them out only through a `NetworkCallback` registered with `FLAG_INCLUDE_LOCATION_INFO`
* **Known networks** - `GetKnownNetworks()` lists what the device has saved as `KnownWifiNetwork` (opaque platform `Id`, SSID, security, hidden), `Forget(id)` deletes one, and `Connect(id)` rejoins one without handing the passphrase over again. The scope differs and the API says which: iOS/Mac Catalyst and Android only ever disclose **your own app's** entries (`NEHotspotConfigurationManager.getConfiguredSSIDs`, network suggestions), while Windows, macOS and Linux hand back every profile on the machine. `Connect(id)` works on Windows, macOS, Linux and Android below API 29 - iOS and modern Android treat a saved network as a standing hint the OS acts on, with no call to force it. The `Id` is the platform's own handle: a NetworkManager connection UUID on Linux, a numeric network id on legacy Android, the SSID everywhere else
* **`IWifiHotspot`** - `Start(...)` returns an `IHotspotSession` carrying the SSID and passphrase actually in use; dispose it to bring the access point down. `GetClients()` lists joined devices with MAC and address on Windows and Linux. Android raises a *local-only* hotspot (clients reach the device, not the internet) and picks the SSID/passphrase itself, Windows shares the machine's internet connection, Linux runs NetworkManager AP mode with DHCP + NAT, and iOS/macOS have no hotspot API at all
* **Backends** - `WifiManager` + `ConnectivityManager` on Android (specifier-based joins from API 29, legacy `WifiConfiguration` below), `NEHotspotConfiguration` + CaptiveNetwork on iOS/Mac Catalyst, CoreWLAN on macOS, `WiFiAdapter` + `NetworkOperatorTetheringManager` + `Radio` on Windows, and NetworkManager over D-Bus on Linux (separate `Shiny.Net.Wifi.Linux` package). Saved profiles are outside WinRT entirely, so Windows reaches `wlanapi.dll` directly for those three calls. On Linux, `Shiny.Net.Wifi.Linux` supplies `AddWifi`/`AddWifiHotspot`; the base package declares them only for the platform targets, so the two never collide. The plain .NET stub (`NetWifiManager`) still reports IP/DNS off the wireless interface and raises `Changed` if you register it yourself; its Wi-Fi-specific calls throw
* **Permissions** - Android needs `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE`, `ACCESS_FINE_LOCATION` and `NEARBY_WIFI_DEVICES` (API 33+); iOS needs the *Hotspot Configuration* and *Access WiFi Information* capabilities plus `NSLocationWhenInUseUsageDescription`; macOS needs the location usage description; Windows needs `wiFiControl` and `radios`; Linux gates the mutating calls behind polkit. A scan that comes back empty because location was refused throws `WifiPermissionException` instead of looking like an empty neighbourhood
* **VPN** - install, connect, disconnect and monitor a profile in the operating system's own IKEv2 VPN client on iOS, Mac Catalyst, macOS, Android and Linux. The OS runs the tunnel, so it keeps running after the app is closed and shows in the system VPN settings. This is not a protocol implementation - WireGuard or a custom protocol needs an Apple packet tunnel extension or an Android `VpnService`, which this package does not provide
* **`IVpnManager`** - `SaveProfile(new VpnProfile { Name, ServerAddress, Authentication = VpnAuthentication.SharedSecret(...) / UsernamePassword(...) / Certificate(pkcs12, password) })` installs or replaces the app's one profile and shows the OS consent prompt the first time, returning `AccessState.Denied` if the user declines. `Connect(timeout)` starts the tunnel and waits until it is connected, throwing `VpnConnectionException` with the OS's reason (authentication rejected, server not responding, ...) if it fails or times out. `Disconnect()`, `RemoveProfile()`, `GetStatus()` and a de-duplicated `StatusChanged` event (current state delivered on subscribe) cover the rest. `RemoteIdentifier`, `LocalIdentifier` and a private-CA `ServerRootCertificate` are optional
* **Platform reach** - every manager publishes `VpnCapabilities`; anything missing throws `VpnNotSupportedException` naming the limit. Apple: everything except `ServerRootCertificate` (a private CA has to come from a configuration profile). Android: needs API 30, and API 33 for `Status` - on Android 11 and 12 `Connect` returns once the OS accepts the start, since there is no way to read the state; the server address is always the remote identity. Linux: shared secret and username/password, no certificate authentication
* **Backends** - `NEVPNManager` + `NEVPNProtocolIKEv2` on iOS/Mac Catalyst/macOS (secrets go into the keychain as persistent references), `VpnManager` + `Ikev2VpnProfile` platform VPN profiles on Android (the OS's own client, not `VpnService`), and NetworkManager's strongSwan plugin over D-Bus on Linux (separate `Shiny.Net.Vpn.Linux` package, which supplies `AddVpn`)
* **Setup** - Apple needs the *Personal VPN* capability (`com.apple.developer.networking.vpn.api` = `allow-vpn`); Android needs nothing in the manifest; Linux needs `network-manager-strongswan` and polkit authorization for saving and connecting
### Location
* **Locations** (`Shiny.Gps` + `Shiny.Geofencing`; `Shiny.Locations` is a meta-package that references both) - foreground/background GPS, geofence monitoring with enter/exit/dwell (dwell is native on Android, a timer on Windows, and calculated from the entry/exit on iOS, where it usually arrives at exit), reverse geocoding on every platform (`IGeocoder` - MapKit/CoreLocation on iOS/Mac Catalyst, `Geocoder` on Android, and the free OpenStreetMap Nominatim API on Windows, Linux, macOS, Blazor WebAssembly and Android devices without Play Services - rate-limited to its one-request-a-second policy and cached), and motion-activity recognition (CMMotionActivity / ActivityRecognition). Background GPS listeners and the motion-activity listener are persisted and re-registered at startup when the OS ends the process, so check `CurrentListener` / `IsListening` before starting one
* **iOS authorization escalates, and `RequestAccess` now escalates with it.** A `CLServiceSession` is bound to the requirement it was opened under, so the when-in-use session a foreground request creates is replaced (not reused) when a background request later needs *always* - an existing always session still covers a foreground request, so a background grant is never torn down to ask for less. Ask from the feature that needs it: the system presents the "always" upgrade prompt once, and that moment is the only one where you can say why. `AccessState.Restricted` is what when-in-use reports as when a background request asks about it - "granted, but not for what you asked for", not "blocked"
* **Region Geofencing** (`Shiny.Geofencing.Regions`) - GPS-driven geofencing with no region limit (iOS, Android, Mac Catalyst). Background GPS readings are filtered by distance/time and resolved against region sets, each tracking its own current region across restarts. Regions come from a pluggable `IRegionSource`: built-in reverse geocoding (country, state/province, county, city, neighbourhood or postal code - online only, no data to ship), or `Shiny.Geofencing.Regions.DocumentDb` - polygon (containment) and point (proximity) documents in a Shiny.DocumentDb SQLite store, with a seeder for US states, Canadian provinces and US & Canadian cities. Replaces `Shiny.DocumentDb.Geofencing`, `Shiny.DocumentDb.Geo` and `Shiny.Spatial.Geofencing`
### Personal Data
* **Contacts** - cross-platform device contact access with CRUD and a fluent async query builder on iOS/Mac Catalyst/macOS (the Contacts framework, one shared implementation) and Android (`ContactsContract`): `await store.Query().Search(text).OrderBy(ContactSortField.FamilyName).ToListAsync(ct)`
* **Calendar** - cross-platform calendar & event access with CRUD and a fluent async query builder on iOS/Mac Catalyst/macOS (EventKit), Android (CalendarContract), and Windows (`AppointmentStore`, best-effort - system events are read-only, writes go to an app-owned calendar). `await store.Query().ForCalendar(id).Between(from, to).OrderBy(CalendarEventSortField.Start).ToListAsync(ct)` - the calendar id and date window are pushed down to the native fetch, which runs off the calling thread. `DeleteEvent(id, deleteSeries)` chooses between removing one occurrence of a recurring event or the rest of the series
* **Call Directory** - block calls, or show your own caller ID for them, from a list of numbers your app keeps, through one `ICallDirectoryManager`. **iOS:** the CallKit Call Directory - `<ShinyCallDirectoryExtension>true</ShinyCallDirectoryExtension>` plus `<ShinyCallDirectoryAppGroup>` builds the Call Directory extension during `dotnet build` (no Xcode project) with its own provisioning profile on device builds; the app writes `Block` / `Identify` entries to a SQLite store in the app group and `Reload()` has iOS load them, incrementally - only numbers changed since the last load iOS accepted are sent. **Android 10+:** the call screening role (`RequestAccess()` shows the system "caller ID & spam app" dialog) and a `CallScreeningService` that ships in the package check every incoming call against the same list - blocking, an `Allow` list, optional `BlockUnlistedCalls`, and a caller ID notification for `Identify` entries (apps cannot change the dialer's caller ID). `ReplaceAll` swaps in a whole server-synced list in one transaction and `PhoneNumbers.ToCallDirectory` normalizes what a person typed. The store uses the platform's own SQLite (no SQLitePCLRaw), so it is trim/AOT safe. Elsewhere `IsSupported` is false and every call is a safe no-op ([docs](https://shinylib.net/client/calldirectory/))
### Devices & Peripherals
* **Printing** - two different printing models, because a receipt printer and an office printer are not the same problem
* **Thermal / receipt printing (`Shiny.Printers`)** - build a protocol-neutral `PrintDocument` fluently (styled text, alignment, 1D barcodes, QR codes, raster images, paper cut, raw bytes) and an `IPrinterProtocol` encodes it - `EscPosProtocol` in full, plus a minimal `TsplProtocol` for label printers. `PrinterCapabilities` carries the "driver constants" a receipt is laid out against (`CharactersPerLine`, `DotsPerLine`, cutter), with `Paper58mm`/`Paper80mm` presets. `PrinterImage.FromPixels` does grayscale + threshold/Floyd-Steinberg dithering + 1bpp packing; decoding the PNG is the caller's job, so the core has no imaging dependency and is AOT/trim clean
* **Transports plug in under the same document** - `Shiny.Printers.BluetoothLE` scans for and connects to BLE printers on the common ESC/POS GATT fingerprints (`18F0/2AF1`, HM-10 `FFE0/FFE1`, ISSC) with MTU-chunked writes; `Shiny.Printers.Network` streams raw TCP to port 9100 (JetDirect) and finds printers over mDNS through `Shiny.Net.Discovery`, or with `Sweep()` - a port-9100 sweep of the local subnet for the many receipt printers that never advertise - and reads paper, cover, cutter and cash-drawer state back with `GetStatus()` (ESC/POS `DLE EOT`; `SupportsStatus` is false on the write-only BLE and browser transports); `Shiny.Printers` itself drives Web Bluetooth, Web Serial and WebUSB from the browser (`AddBrowserPrinting()`), where the device chooser replaces the scan. Dispose the `Printer` a manager hands back to disconnect
* **OS-native printing (`Shiny.Printing`)** - `IPrintService.Print(PrintJob.Pdf/Image/Html/File(...))` hands content to the platform pipeline so any installed printer works. HTML prints through a real browser engine (`WKWebView` / `WebView`) so CSS layout and page breaks survive, and `IPrintService.HtmlToPdf` lays HTML out as a PDF to share instead of print on iOS, Mac Catalyst and Android: AirPrint on iOS/Mac Catalyst, `PrintManager` on Android, GDI+ and the shell `print` verb on Windows, and CUPS (`lp`/`lpstat`) on Linux and macOS. Silent printing to a named printer and printer enumeration where the OS allows it - feature-detect with `IPrintService.Capabilities` rather than catching. On Blazor, `Shiny.Printing` prints through `window.print()`
* **`Shiny.Printing.Rendering`** renders the thermal `PrintDocument` to a PDF with SkiaSharp, so the same receipt can go to either kind of printer. On Linux add `SkiaSharp.NativeAssets.Linux` to the app - SkiaSharp's plain `net10.0` build only carries the macOS and Windows natives
* **Permissions** - BLE printing needs the usual Bluetooth permissions; network printing needs `NSLocalNetworkUsageDescription` plus `_pdl-datastream._tcp` and `_printer._tcp` in `NSBonjourServices` on Apple platforms, or iOS blocks both the browse and the socket. A sandboxed macOS app needs `com.apple.security.print` to reach CUPS
* **Gamepads** - read game controllers on Android, iOS, tvOS, Mac Catalyst, macOS, Windows, Linux and Blazor WebAssembly, with rumble, battery, motion sensors and light bars where the hardware and the platform both allow it
* **Buttons are named by position, not by the label on the pad.** `GamepadButton.A` is the bottom face button everywhere - A on Xbox, Cross on PlayStation, B on a Nintendo pad, whose face buttons are mirrored - so "A jumps" means the same thing on every controller. `IGamepad.Kind` is there for drawing the right glyph, and changes nothing about reading input. Stick Y is positive *upwards* on every backend; Android, Linux and the browser all report it downwards natively and each one flips it, rather than leaving six inconsistent conventions in the open
* **Both ways of reading a controller, because games need both.** `pad.GetState()` returns the whole pad as one immutable `GamepadState` - buttons as a bitmask, both sticks, both triggers - which is what a render loop wants and which never blocks. `ButtonChanged` and `AxisChanged` report the transitions, which is what menus want and which saves writing edge detection. The events are derived from the same state on every platform, so they cannot disagree with it: one event per button, never a combined mask (a handler switching on `e.Button` would otherwise silently miss the second of two simultaneous presses)
* **`IGamepadManager`** - `await manager.GetGamepads()` starts the watch and returns what is connected; after that `Connected`/`Disconnected` keep the list current and nothing else is asynchronous. Registering the service touches no hardware - a console host that resolves the manager and never asks for a controller opens no device node, hooks no activity and subscribes to no notification centre. `AxisChangeThreshold` (0.02 by default) is measured against the value each axis *last reported*, not against the previous state, so a slow stick sweep still produces events where a per-frame comparison would produce none; a stick landing exactly on centre or full deflection always reports, because swallowing the return to zero leaves the player walking into a wall
* **`GamepadStick.WithDeadzone(0.15f)`** is radial and rescales what is left. Deadzoning each axis independently is the classic bug that makes a stick feel like it has eight directions: a small X is discarded while a large Y survives, and the stick snaps to the compass points. Without the rescale the value jumps from 0 straight to the deadzone the moment it leaves the dead area, and slow movement is impossible
* **Rumble, battery, motion and lights are capability-gated per controller, not per platform.** The same DualSense reports motion and a light bar over USB on Linux and neither of them in a browser, because what is missing is the API rather than the hardware. Anything not in `IGamepad.Capabilities` throws `GamepadNotSupportedException` naming the flag *and the platform reason*. `SetVibration` sets a level that holds until changed - `GamepadVibration.Off` stops it, and `pad.Pulse(0.8f, TimeSpan.FromMilliseconds(120))` does the one-shot bump, stopping the motors even if it is cancelled. `LowFrequency` is the heavy left-handle weight and `HighFrequency` the light right-hand buzz; trigger motors are a separate claim and are never silently folded into the handles
* **Backends** - GameController on iOS/tvOS/Mac Catalyst/macOS, one implementation for all four (the AppKit/UIKit split does not reach down to controllers), with rumble driven through Core Haptics: Apple exposes no motors, so each haptic locality runs a continuous infinite-duration pattern whose intensity is moved by a dynamic parameter, because restarting a pattern per frame is audible. `InputDevice` + `VibratorManager`/`LightsManager`/`SensorManager` on Android. `Windows.Gaming.Input` on Windows, polled, because that API has no input callback at all. evdev on Linux (in `Shiny.Gamepad`'s `net10.0` build, picked with `OperatingSystem.IsLinux()`) - ioctls straight to `/dev/input/event*` with no libevdev, force-feedback rumble, and battery and light bar read from the controller's sysfs subtree. The W3C Gamepad API in the browser (same build, picked with `OperatingSystem.IsBrowser()`), where the polling loop lives in JavaScript on `requestAnimationFrame` and only calls into .NET when something changed - a player holding a controller still costs zero interop calls
* **Android is the platform with no way to ask a controller what it is doing.** Input arrives only as key and motion events dispatched to the focused activity, so this package wraps that activity's window callback - above the whole view hierarchy, so a gamepad press is seen even where a text field would have swallowed it - and follows the current activity through Shiny's lifecycle without the app doing anything. Events are observed and passed straight on by default, so the D-pad still moves focus and B still goes back; `AndroidGamepadManager.ConsumeEvents` keeps them for a game. State is accumulated from events, so a button held while the app is backgrounded is cleared on focus loss rather than staying stuck down
* **Permissions** - none on any platform; controllers are not a privacy surface. Two caveats that look like permissions and are not: **the browser hides a connected controller until the player presses something on it** (an anti-fingerprinting rule with no prompt to grant - show "press any button on your controller", not "no controller found"), and **Linux needs read access to `/dev/input/event*`**, which usually means the `input` group. Rumble on Linux additionally needs *write* access to the same node, and the light bar needs write access to its sysfs LED - the node is opened read-write when permitted and read-only otherwise, so a controller always works and simply reports no `Vibration` where the permission is missing. `Windows.Gaming.Input` reports every stick centred while the app is not in the foreground, which is the API's own behaviour and not something the app can opt out of
* **Wearables** - talk to a companion Apple Watch (WatchConnectivity) or Wear OS (Data Layer) app: `IWearableManager` reports pairing and reachability as separate questions - `IsConfigured` (paired, companion app installed) survives the watch being switched off or out of range, while `IsReachable` means a live message can go now, and every known watch is listed connected or not so an absent one can still be named (on Wear OS it is found through the companion app's capability, because the connected-node list drops a watch the moment it disconnects) - sends live messages and awaits the companion's reply, shares the latest context both ways, and queues data and file transfers that survive the watch being out of range - payloads go as key/values (sent as a JSON object) or plain text, with no hand-encoding to bytes or JSON, and received messages read back with `GetString()` / `GetValues()`. Everything the watch sends reaches an `IWearableDelegate`, with the app woken in the background for it; a delegate's return value is the reply to the watch's message. `WearableProtocol` documents the wire format for Swift and Kotlin companions, and on Wear OS the same package runs inside a .NET watch app. `AddAccessorySetup()` registers Bluetooth LE (`AddBluetoothLE()`) too. iOS and Android; elsewhere only Bluetooth LE is registered
* **Screen Recording** - record the screen to an MP4 with optional microphone and system audio
* **What "the screen" means is not the same everywhere, and the API says so rather than pretending otherwise.** Android, macOS, Windows and Linux record the system screen, so other apps end up in the file. iOS and Mac Catalyst record **your own app's UI only** - that is all ReplayKit offers without a Broadcast Upload Extension, which a NuGet package cannot deliver. Every recorder publishes a `ScreenRecorderCapabilities` flags property; anything unavailable throws `ScreenRecorderNotSupportedException` naming the specific limit. The flags are read off the instance because they differ *within* a platform: macOS 15 gains microphone capture and loses pause (`SCRecordingOutput` writes the file itself and cannot be detached mid-recording) where macOS 12.3-14 has it the other way round
* **`IScreenRecorder`** - `await recorder.Start(new ScreenRecordingRequest { IncludeMicrophone = true, MaxWidth = 1280 })` returns an `IScreenRecording` that does not come back until frames are genuinely being written - so a consent dialog, a compositor picker or an Android foreground-service promotion all complete first. `Pause()`/`Resume()` close the gap in the timeline rather than leaving a frozen stretch, `Stop()` returns a `ScreenRecordingResult` with the path, duration, size and the **`MimeType` actually produced** (browsers disagree - Safari and Chrome give MP4, Firefox gives WebM), and disposing without stopping cancels and deletes the partial file. A request asking for something outside `Capabilities` throws *before* any native call, because a recording that silently came out without the microphone is worse than one that refused to start
* **`GetTargets()`** lists displays, windows and applications on macOS and Windows, so an app can offer its own picker. Linux and the browser hand selection to the compositor and throw here - their picker appears during `Start` instead
* **The OS ending it on its own is a first-class outcome.** `IScreenRecording.Faulted` fires with a reason - the user hit Android's cast notification or the browser's "Stop sharing" bar, iOS took the screen for an incoming call, a monitor was unplugged, the encoder failed - and carries whatever was salvaged, so a recording cut short still yields a playable file rather than a silent truncation. `MaxDuration` stops cleanly and reports the same way
* **Backends** - MediaProjection + MediaCodec/MediaMuxer on Android (MediaRecorder takes a single audio source and playback capture is not one of them, so app audio forces the whole pipeline down to MediaCodec), ReplayKit `startCapture` into an `AVAssetWriter` on iOS/tvOS/Mac Catalyst (`startRecording` only surrenders its file through a share sheet, which is no use to a library), ScreenCaptureKit on macOS - `SCRecordingOutput` on 15+ and the same `AVAssetWriter` below it - Windows.Graphics.Capture into a `MediaStreamSource` transcoded by Media Foundation on Windows, and the xdg-desktop-portal ScreenCast API driving `gst-launch-1.0` or `ffmpeg` on Linux (separate `Shiny.ScreenRecorder.Linux` package, `AddLinuxScreenRecorder()`, so its D-Bus dependency stays out of WebAssembly apps). Blazor WebAssembly uses `getDisplayMedia` + `MediaRecorder` (in `Shiny.ScreenRecorder`'s `net10.0` build, picked with `OperatingSystem.IsBrowser()`) and is the only platform where pause is native and the result has **no file path** - there is no filesystem, so `OpenRead()` is the portable accessor
* **Permissions** - Android needs `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_MEDIA_PROJECTION` and `RECORD_AUDIO` for audio, plus a per-recording consent dialog that cannot be pre-granted - it asks for the entire screen, so Android 14+'s "a single app" picker never appears - and the recording notification carries a Stop action that ends the recording like the system's own control (reword it with `IScreenRecordingNotificationDelegate`); iOS needs `NSMicrophoneUsageDescription` and the app in the foreground; macOS needs the Screen Recording grant (and a relaunch after it is first given); Windows needs 1903+ and the `graphicsCapture` capability when packaged; Linux needs a portal plus GStreamer or FFmpeg, all probed at runtime so a machine missing them reports `None` rather than failing at record time. **Windows has no audio at all** - `Windows.Graphics.Capture` captures pixels only
* **Biometrics** - verify the device owner with the platform's own prompt through one `IBiometrics`: **LocalAuthentication** on iOS, Mac Catalyst and macOS (Touch ID, Face ID, Optic ID - and a paired Apple Watch on macOS when the passcode is allowed), **AndroidX BiometricPrompt** on Android (fingerprint, face, iris; `Strong` / `Weak` class) and **Windows Hello** (`UserConsentVerifier`, parented to the app window). `GetAvailability()` reports a `BiometricStatus` (`Available`, `NotEnrolled`, `LockedOut`, `PasscodeNotSet`, ...), the `BiometricType` hardware present and whether a passcode is set, with no UI; `await biometrics.Authenticate(new BiometricRequest { Reason = "Unlock your vault", AllowDeviceCredential = true })` returns a `BiometricResult` (`Success`, `Cancelled`, `Fallback`, `LockedOut`, ...) instead of throwing, and a cancelled token dismisses the prompt. Windows Hello always offers its PIN and never says which method was used. The result is a local yes/no - gate UI with it, don't treat it as a credential. **iOS needs `NSFaceIDUsageDescription` in Info.plist.** Linux, Blazor and plain .NET report `NotSupported`. Register with `services.AddBiometrics()` ([docs](https://shinylib.net/client/biometrics/))
* **NFC** - read and write **NDEF** tags (URLs, text, MIME payloads, smart posters, Android app records) through one `INfcManager`. NDEF only, on purpose - payment cards, passports, MIFARE Classic and raw tag commands are out of scope. **Android** uses reader mode on the current activity (re-armed across rotation and activity changes), **iOS** a CoreNFC `NFCNDEFReaderSession` (the scan sheet stays up for the whole scan; no tag UID), **Windows, macOS and Linux** a PC/SC contactless reader such as an ACR122U (`winscard.dll`, `PCSC.framework`, `pcsc-lite`), where the library speaks NDEF itself to NFC Forum Type 2 (NTAG, Ultralight) and Type 4 tags, and **Blazor WebAssembly** Web NFC (Chrome on Android). A scan is an `IAsyncEnumerable<INfcTag>` whose session lives exactly as long as the `await foreach`: `await foreach (var tag in nfc.Scan(ct: ct)) { var msg = await tag.ReadNdef(); await tag.WriteNdef(new NdefMessage(NdefRecord.CreateUri("https://shinylib.net"))); }`, or the one-tap `ReadOne()` / `WriteOne(message)`. `NdefMessage` / `NdefRecord` encode and parse NDEF on every platform. `Capabilities` reports what the platform can do; `MakeReadOnly()` locks a tag permanently. **Android needs `android.permission.NFC`; iOS needs `NFCReaderUsageDescription` and the `com.apple.developer.nfc.readersession.formats` entitlement (`TAG`); Linux needs `pcscd`; a sandboxed macOS app needs `com.apple.security.smartcard`.** Register with `services.AddNfc()` ([docs](https://shinylib.net/client/nfc/))
### Commerce
* **In-App Purchases** - consumables, non-consumables and auto-renewable subscriptions sold through the App Store and Google Play, from one `IInAppPurchaseManager`: **StoreKit 2** on iOS (through a bundled Swift shim - StoreKit 2 is Swift-only, and StoreKit 1 is deprecated) and **Google Play Billing 9** on Android. Products, purchases, entitlements, restore and subscription management, including Google base plans, offers and free trials and Apple introductory offers. Pending purchases (Ask to Buy, slow payment methods), renewals, refunds and purchases made on other devices reach your `IPurchaseDelegate` from app launch. **Nothing auto-finishes** - verify `Purchase.VerificationData` on your server, grant, then `FinishPurchaseAsync(purchase, consume)`, so a crash between payment and granting never loses a purchase (Google refunds anything unacknowledged after 3 days). `PurchaseOptions.AccountToken` (a `Guid`) ties purchases to your user and comes back in server notifications. **`Shiny.Mobile.InAppPurchases.Server`** is the ASP.NET Core half: `AddInAppPurchaseServer(...)` + `MapInAppPurchaseWebhooks("/iap")` receive App Store Server Notifications V2 (JWS verified offline against Apple Root CA G3) and Google Play RTDN over Pub/Sub push (OIDC-authenticated) as one `PurchaseEvent` stream for your `IPurchaseEventHandler`s, with de-duplication and store retries (a throwing handler returns a 5xx); `MapInAppPurchaseVerification` and `IPurchaseVerifier` verify purchases the app reports, and `IAppleStoreClient` / `IGooglePlayClient` wrap the App Store Server API and Play Developer API. BCL-only crypto, AOT-compatible. iOS and Android only - this is not Apple Pay / Google Pay card processing, which goes through a payment processor ([docs](https://shinylib.net/client/inapppurchases/))
* **App Store** - `IAppStore` asks the store the app ships through whether a newer version is published (`GetCurrent()` - store and running versions, `NeedsUpdate`, plus release notes, release date, ratings and minimum OS where the store gives them), opens the store and review pages, and requests the native in-app review prompt. The Apple App Store and Mac App Store (iTunes lookup, StoreKit prompt), Google Play (Play in-app review), the Microsoft Store (DisplayCatalog, `StoreContext`), and Flatpak/Snap on Linux (`appstream://` software-centre links), all implemented natively with no MAUI. Register with `services.AddAppStore()` ([docs](https://shinylib.net/client/appstore/))
* **Age Signals** - the user's age range as the app store shares it, for the app store accountability laws (Texas SB 2420 - in force since the Fifth Circuit's June 2026 stay - plus Utah, Louisiana and California AB 1043) and any app that gates features by age. One `IAgeSignals` over **Apple Declared Age Range** (iOS / macOS 26+, through a bundled Swift shim - the framework is Swift-only) and **Google Play Age Signals** (Android, the 0.0.4 AAR bound inside the package): `var signal = await ageSignals.RequestAgeRange(13, 16, 18)` returns a `Status` (`Shared`, `Declined`, Play's `VerificationRequired`, `NotSupported`), the bounds, and an `AgeRangeSource` ordered weakest first (`SelfDeclared` < `GuardianDeclared` < `Confirmed` < `IdVerified`) so an app can require a minimum. `signal.IsAtLeast(16)` and `IsAdult` return `bool?` - null when the store can't tell - so "unknown" is never read as "no". `GetEligibility()` reports Apple's `isEligibleForAgeFeatures` and 26.4 regulatory flags with no UI; `ShowSignificantUpdate(...)` presents Apple's significant-update acknowledgment (iOS 26.4+), and Play's guardian approval of significant changes comes back on `AgeSignal.SignificantChange` with the `InstallId` used for revocations. The age gates are Apple's - Play's bands come from Play Console. Nothing is cached. **Apple needs the `com.apple.developer.declared-age-range` entitlement - without it the call never returns.** Mac Catalyst, Windows, Linux and Blazor report `NotSupported`. Register with `services.AddAgeSignals()` ([docs](https://shinylib.net/client/agesignals/))
### Games
* **Game Center** - achievements, leaderboards and friends over **Apple Game Center** (GameKit on iOS, Mac Catalyst and macOS) and **Google Play Games Services v2** (Android) from one `IGameCenterManager` ([skill](skills/shiny-gamecenter/SKILL.md))
* **One set of keys.** `AddGameCenter(o => o.AddAchievement("first_win", appleId: "...", googleId: "CgkI...").AddLeaderboard("high_score", ...))` maps your logical keys to each store's ids once; unregistered keys pass straight through as platform ids. An achievement registered for only one store is skipped on the other
* **Progress never fails and never double counts.** `UnlockAsync`, `SetProgressAsync`, `IncrementAsync`, `RevealAsync` and `SubmitScoreAsync` record to a durable JSON queue first and then send, so an offline or signed-out player loses nothing - the queue drains on sign-in, when connectivity returns and after every call, and survives restarts. Progress is held in steps and sent as an *absolute* value (Apple percent / Google `setSteps`), so a retry after a crash is idempotent and calls that would not move an achievement forward are skipped. Queued work stays with the player who earned it when accounts switch on a shared device; work recorded before anyone signed in goes to whoever signs in first
* **Leaderboards and friends** - `GetScoresAsync(key, LeaderboardScope.Friends, LeaderboardTimeScope.Week)` loads global or friends-only scores; `GetFriendsAsync()` returns the friends visible to the game after the platform consent prompt (`FriendsAccessStatus` reports Denied / Restricted / Google's `ConsentRequested`)
* **Backends** - GameKit with the dashboard opened through `GKAccessPoint` (GKGameCenterViewController is deprecated in the 26 SDKs; it remains only for single-leaderboard views on iOS 15-17). Play Games v2 through `Xamarin.GooglePlayServices.Games.V2`, using the `*Immediate` calls so the queue only clears once Google's servers have the data; `PlayersClient` is not in that binding and is reached through JNI. `IGameServicesProvider` is public, so another service (Steam) can plug in behind the same manager
* **Setup** - Apple: `com.apple.developer.game-center` entitlement, Game Center enabled on the App Store Connect version, and `NSGKFriendListUsageDescription` for friends. Android: `com.google.android.gms.games.APP_ID` manifest meta-data and the SHA-1 of every signing key (debug, upload, Play App Signing) registered as OAuth credentials
### AI & Assistants
* **App Functions** - declare what your app can do **once in C#** and hand it to the assistants: Siri, Spotlight, Shortcuts and Apple Intelligence as App Intents (iOS 16+), and Gemini and other agents as Android AppFunctions (Android 16+). An `[AppFunction("create_order")]` record implementing `IAppFunction<TResult>` is the request, an `IAppFunctionHandler<TRequest, TResult>` runs it in its own DI scope, `[AppEntity]` records resolved through an `IAppEntityQuery<T>` become pickable parameters (plus a generated `search_{entity}` function on Android, which has no entity queries), and `IAppFunctionDelegate`s gate every call (`AppFunctionGate.Allow` / `Deny` / `OpenApp`) and observe its outcome. `context.IsForeground` tells a handler the user asked from inside the app (a Siri or Gemini overlay, on both platforms) - it runs in the live app, so it can update the screen, and an `OpenApp` gate passes without a prompt. `[AppFunction(OpensApp = true)]` marks a function that needs the app on screen: iOS brings the app forward first, and Android runs it only while the app is visible and otherwise refuses it. A bundled source generator writes `services.AddAppFunctions()`, reflection-free binding and dispatch, the Swift App Intents (with `[AppShortcut]` Siri phrases) and the Android AppFunctions schema; its build task compiles the Swift into the app (on a macOS build host) and adds the schema assets - no Swift, Kotlin, plist or manifest entries to write. Functions must be declared in the app project. iOS and Android; the base target carries the contracts and the dispatcher, `Shiny.AppFunctions.Extensions.AI` hands the same functions to an in-app LLM as tools (see [AI Tools](#ai-tools)), and every function's JSON schema is exposed for MCP adapters
* **AI Tools** - expose Contacts, Calendar, Notifications, Locations and your App Functions to LLM agents as `Microsoft.Extensions.AI` tools - see [AI Tools](#ai-tools) below
## tvOS
`Shiny.Core`, `Shiny.Hosting.Native`, `Shiny.BluetoothLE`, `Shiny.Net.Discovery`, `Shiny.Jobs`, `Shiny.Net.Http`, `Shiny.Push`, `Shiny.ScreenRecorder`, `Shiny.Gamepad` and `Shiny.Data.Sync` ship a `net10.0-tvos` target. tvOS reuses the iOS platform layer wholesale - the same `ShinyAppDelegate`, `IosPlatform`, `IosLifecycleExecutor` and `IIosLifecycle` hooks - so there is no tvOS-specific code to write. There is no MAUI on tvOS, so a tvOS head hosts through `Shiny.Hosting.Native`.
What Apple withholds on tvOS, and what Shiny does about it:
* **No BLE peripheral role.** `CBMutableService` and `CBMutableCharacteristic` have no constructors on tvOS, so `Shiny.BluetoothLE.Hosting` has no tvOS target. The central role is complete, L2CAP included
* **No background Bluetooth.** tvOS has no `bluetooth-central` background mode, so scans and connections end with app suspension and `RestoreIdentifier` has nothing to restore
* **Notifications are badge-only.** `UNNotificationContent` carries no title, body or user info and there is no `UNNotificationResponse`, so `IIosLifecycle.INotificationHandler` does not exist on tvOS and `Shiny.Push` raises `OnReceived` (silent push) but never `OnEntry`
* **No microphone.** `ScreenRecorderCapabilities.Microphone` is not advertised on tvOS; everything else ReplayKit does on iOS it does on tvOS
* **No battery.** `IBattery` reports permanently full, on AC, energy saver off - an Apple TV is mains powered and `UIDevice` carries no battery API there
* **The Siri Remote is a gamepad.** `Shiny.Gamepad` reports it as a `GCMicroGamepad` with `GamepadKind.Remote` - no sticks, no triggers, two buttons - and maps its analog touch surface onto the left stick, so menu code written against a controller works unchanged. A real MFi controller paired to the Apple TV reports everything it would on iOS
* **No Wi-Fi API and no geofencing.** `NEHotspotConfiguration`/`NEHotspotNetwork` and `CLMonitor` are absent, so `Shiny.Net.Wifi`, `Shiny.Gps` and `Shiny.Geofencing` have no tvOS targets
A working UIKit tvOS app exercising all of it lives in [`samples/Sample.tvOS`](samples/Sample.tvOS).
## AI Tools
Optional `*.Extensions.AI` packages expose Shiny modules as [`Microsoft.Extensions.AI`](https://learn.microsoft.com/dotnet/ai/) tool functions (`AIFunction`s) for LLM agents. You opt-in exactly which operations the model can see - a read/write allow-list you control on behalf of the agent (this is *not* an OS permission prompt; the underlying platform permissions must already be granted). Resolve the generated `*AITools` bundle from DI and pass `.Tools` to any `IChatClient`. All are AOT-compatible (hand-built schemas, no reflection).
* **Shiny.Contacts.Extensions.AI** - `AddContactsAITools(...)` → `search_contacts`, `get_contact`, and (write) `create_contact`, `update_contact`, `delete_contact`
* **Shiny.Calendar.Extensions.AI** - `AddCalendarAITools(...)` → `list_calendars`, `search_events`, `get_event`, and independently opt-in `create_event`, `update_event`, `delete_event` (per-operation `Read`/`Create`/`Update`/`Delete` capability flags). Capabilities apply to every calendar by default, or per calendar id - `AddCalendar(workId, All)` alongside a global `Read` gives the agent read-only access everywhere but read/write on one calendar, and `None` hides a calendar entirely
* **Shiny.Notifications.Extensions.AI** - `AddNotificationAITools(...)` → reminder-framed `list_reminders`, and (write) `create_reminder` (one-time or daily), `cancel_reminder`
* **Shiny.Locations.Extensions.AI** - `AddLocationAITool()` → read-only `get_current_location`, `get_distance_to`, `estimate_travel_time`
* **Shiny.AppFunctions.Extensions.AI** - `AddAppFunctionAITools(...)` → your own App Functions - the ones Siri and Gemini call - as tools, named by their function ids (`create_order`, `search_customer`, …). Opt in with `AddAllFunctions()` or `AddFunction("create_order")` (a function with an entity parameter brings its `search_{entity}` function along) and hide some with `ExcludeFunction(...)`. Calls run through `AppFunctionDispatcher`, so binding, your `IAppFunctionDelegate` gates and error mapping are the same as for Siri and Gemini; the calls count as foreground (the chat runs in the app), so `OpensApp` functions run and `OpenApp` gates pass
```csharp
builder.Services.AddContactStore();
builder.Services.AddContactsAITools(b => b.AddContacts(ContactAICapabilities.ReadWrite));
builder.Services.AddNotifications();
builder.Services.AddNotificationAITools(b => b.AddReminders(ReminderAICapabilities.ReadWrite));
builder.Services.AddGps();
builder.Services.AddLocationAITool();
builder.Services.AddAppFunctions(); // source-generated
builder.Services.AddAppFunctionAITools(b => b.AddAllFunctions().ExcludeFunction("cancel_order"));
// later, hand the tools to a chat client
var tools = sp.GetRequiredService<ContactAITools>().Tools;
var response = await chatClient.GetResponseAsync(messages, new ChatOptions { Tools = [.. tools] });
```
## Commercial Support
Shipping this to production? [ShinySoft Technologies](https://shinysoft.biz), the company that builds and maintains the Shiny libraries, offers priority issue response, support agreements, architecture reviews and private office hours. See [Commercial Support](https://shinylib.net/foundation/support/?utm_source=github&utm_medium=readme#commercial-support) or [get in touch](https://shinysoft.biz/contact?utm_source=github&utm_medium=readme). Community support through GitHub issues and discussions stays free.
## Links
* [Documentation](https://shinylib.net)
* [Change Log](https://shinylib.net/client/release-notes/)
* [Community Support](https://github.com/shinyorg/shiny/discussions)
* [NuGets](https://www.nuget.org/profiles/ShinyLib)
* [AI Coding Skills](https://shinylib.net/foundation/ai-skills/)