Platform
IPlatform is the small cross-platform surface every Shiny module uses to find a place for files and to get onto the UI thread, and that apps use to ask what they are running on. Everything is implemented natively per platform - no MAUI or Essentials - so it works the same in a MAUI app, a native app, a Linux desktop or a console. Inject it anywhere:
public interface IPlatform{ void InvokeOnMainThread(Action action); DirectoryInfo AppData { get; } DirectoryInfo Cache { get; } DirectoryInfo Public { get; }
Version AppVersion { get; } string? ApplicationIdentifier { get; } string DeviceManufacturer { get; } string DeviceModel { get; } Version? PlatformVersion { get; } string Platform { get; } DeviceFormFactor DeviceFormFactor { get; }
CultureInfo CurrentCulture { get; } event EventHandler<CultureInfo>? CultureChanged; TimeZoneInfo CurrentTimeZone { get; } event EventHandler<TimeZoneInfo>? TimeZoneChanged;
void OpenAppSettings();}Device and App Info
Section titled “Device and App Info”| Platform | AppVersion |
DeviceManufacturer / DeviceModel |
PlatformVersion |
Platform |
DeviceFormFactor |
|---|---|---|---|---|---|
| Android | Package versionName |
Build.MANUFACTURER / Build.MODEL |
Build.VERSION.RELEASE |
Android |
UiModeManager (TV, Car, Watch, Desk), else Tablet from 600dp smallest width, else Phone |
| iOS / tvOS | CFBundleShortVersionString |
Apple / hardware identifier (iPhone16,1; the simulated model on a simulator) |
UIDevice.SystemVersion |
iOS / tvOS |
UIDevice.UserInterfaceIdiom |
| Mac Catalyst | CFBundleShortVersionString |
Apple / hw.model (Mac15,3) |
UIDevice.SystemVersion |
MacCatalyst |
Desktop |
| macOS (AppKit) | CFBundleShortVersionString |
Apple / hw.model |
NSProcessInfo.OperatingSystemVersion |
macOS |
Desktop |
| Windows | Package version, or the entry assembly’s when unpackaged | EasClientDeviceInformation system manufacturer / product name |
Environment.OSVersion |
Windows |
AnalyticsInfo device family |
| Linux | Entry assembly version | DMI sys_vendor / product_name, or the device-tree model on a Raspberry Pi |
Environment.OSVersion |
Linux |
Desktop |
| Blazor WebAssembly | Entry assembly version | Unknown |
null |
Browser |
Unknown |
ApplicationIdentifier is the app’s store/package identifier, or null where the app has none:
| Platform | ApplicationIdentifier |
|---|---|
| Android | Package name (applicationId) |
| iOS / tvOS / Mac Catalyst / macOS | Bundle identifier (CFBundleIdentifier) |
| Windows | Package identity name when packaged, null when unpackaged |
| Linux | FLATPAK_ID or SNAP_NAME inside a Flatpak or Snap, otherwise null |
| Blazor WebAssembly / other plain .NET | null |
DeviceFormFactor is Shiny.DeviceFormFactor - Unknown, Phone, Tablet, Desktop, TV, Watch or Car.
Blazor WebAssembly
Section titled “Blazor WebAssembly”Plain net10.0 has no hosting layer to register IPlatform, so a Blazor app does it itself:
using Shiny.Infrastructure;
builder.Services.AddShinyCoreServices(); // IPlatform (NetPlatform) and the key/value storesThe browser-only extras live on the concrete NetPlatform - inject it directly:
| Member | Notes |
|---|---|
UserAgent |
navigator.userAgent, read once through the runtime’s own JavaScript interop (no script file). null outside a browser, including Blazor Server |
UserAgentVersion |
A best-effort browser version parsed from the user agent - Edge, Opera, Firefox, Chrome, then Safari, most specific first - or null |
OpenAppSettings() opens the app’s own page in the system settings on Android, iOS, Mac Catalyst and packaged Windows apps, and the system settings themselves elsewhere (System Settings on macOS, GNOME Settings or Plasma System Settings on Linux). It does nothing in the browser.
Culture and Time Zone
Section titled “Culture and Time Zone”CurrentCulture and CurrentTimeZone always read the live value. CultureChanged and TimeZoneChanged start their native listener with the first subscriber and stop it with the last, so an app that never subscribes pays nothing. They are raised only when the value actually changes, on whatever thread the OS reports it - marshal with InvokeOnMainThread before touching UI.
| Platform | Culture | Time zone |
|---|---|---|
| Android | ACTION_LOCALE_CHANGED broadcast |
ACTION_TIMEZONE_CHANGED broadcast |
| iOS / tvOS / Mac Catalyst / macOS | NSCurrentLocaleDidChangeNotification |
NSSystemTimeZoneDidChangeNotification |
| Windows | SystemEvents.UserPreferenceChanged (Locale) |
SystemEvents.TimeChanged |
| Linux | 30-second poll - a Linux locale switch only applies at the next login | /etc/localtime file watch, falling back to a 30-second poll |
| Blazor WebAssembly / other plain .NET | 30-second poll | 30-second poll |
Screen
Section titled “Screen”IScreenInfo is registered next to IPlatform by every host. It reports the main display and locks the app’s orientation, natively and without MAUI.
public class VideoPage(IScreenInfo screen){ public async Task EnterFullScreen() { var m = screen.Metrics; // ScreenMetrics(Width, Height, Density, Orientation, RefreshRate) Console.WriteLine($"{m.WidthIndependent}x{m.HeightIndependent} @ {m.RefreshRate}Hz");
screen.OrientationChanged += (_, orientation) => { /* off the UI thread - marshal first */ };
var locked = await screen.SetOrientation(DeviceOrientation.Landscape); // ... await screen.ResetOrientation(); }}Width and Height are physical pixels as the display is currently oriented, and WidthIndependent / HeightIndependent divide them by Density. MetricsChanged and OrientationChanged start watching with the first subscriber and stop with the last, and are raised only when something actually changed. The orientation enum is Shiny.DeviceOrientation (Unknown, Portrait, Landscape) - named so it does not clash with MAUI’s DisplayOrientation or Android’s ScreenOrientation.
| Platform | Metrics | Changes | SetOrientation |
|---|---|---|---|
| Android | Default display’s real size, density and refresh rate | DisplayManager listener |
The current Activity’s requested orientation (sensor portrait / landscape) |
| iOS | UIScreen.MainScreen |
Device rotation, re-read once the interface has turned, and screen mode changes | iOS 16+ through RequestGeometryUpdate - see below |
| Mac Catalyst / tvOS | UIScreen.MainScreen |
Screen mode changes | Not supported (returns false) |
| macOS | NSScreen.MainScreen |
Screen parameter and window screen changes | Not supported |
| Windows | Primary monitor’s current mode and system DPI | SystemEvents.DisplaySettingsChanged |
Auto-rotation preference - only devices that rotate honour it |
| Blazor WebAssembly | window.screen × devicePixelRatio, Screen Orientation API |
resize and orientation change |
screen.orientation.lock() - only for a full-screen page or installed app |
| Linux / other plain .NET | ScreenMetrics.Empty |
Never | Not supported |
For foldables and dual-screen devices - where the fold is, tabletop and book posture - see Screen Posture.
On Blazor WebAssembly the browser viewport (window.innerWidth / innerHeight) is on the concrete BrowserScreenInfo (namespace Shiny.Infrastructure) as ViewportWidth / ViewportHeight. Its JS module loads on first use - UseShiny() loads it up front - and Metrics is ScreenMetrics.Empty until then.
Directories
Section titled “Directories”| Platform | AppData |
Cache |
Public |
|---|---|---|---|
| Android | Context.FilesDir |
Context.CacheDir |
GetExternalFilesDir(null) (can be null if external storage is unavailable) |
| iOS / tvOS / Mac Catalyst | Library |
Library/Caches |
Documents |
| macOS (AppKit) | Library |
Library/Caches |
Documents |
| Windows (packaged) | ApplicationData.Current.LocalFolder |
LocalFolder/Cache |
LocalFolder/Public |
| Windows (unpackaged) / plain .NET | LocalApplicationData/<entry assembly> |
…/Cache |
…/Public |
On Linux, LocalApplicationData resolves to $XDG_DATA_HOME (falling back to ~/.local/share), so the plain .NET layout already follows XDG. The folder is named after the entry assembly so that apps don’t write into a directory the whole desktop shares.
ResourceToFilePath(assembly, resourceName) copies an embedded resource into AppData the first time it is asked for, then returns the path. Use it for native APIs that only accept a file, such as a bundled database or model.
Main Thread
Section titled “Main Thread”InvokeOnMainThread runs the action inline if you are already on the main thread and posts it otherwise. The extensions in PlatformExtensions add async versions that complete when the work has run and pass exceptions back to the caller:
await platform.InvokeOnMainThreadAsync(() => label.Text = "Done");var result = await platform.InvokeOnMainThreadAsync(() => ReadSomeUiState());
// async work that must start on the UI threadawait platform.InvokeTaskOnMainThread(async () => await ShowDialogAsync(), cancellationToken);Windows and plain .NET have no toolkit-independent main thread, so WindowsPlatform.MainThreadHandler and NetPlatform.MainThreadHandler are static hooks for the hosting layer to set. UseShiny() sets the Windows hook to the WinUI dispatcher, and in a MAUI app on a non-platform backend such as Linux GTK it sets the NetPlatform hook to that backend’s MAUI dispatcher. A GTK or other desktop head without MAUI should set the NetPlatform hook to its own main loop:
NetPlatform.MainThreadHandler = action => GLib.Idle.Add(() => { action(); return false; });If nothing sets the handler, the action runs inline. Code with no UI thread still works; it just isn’t marshalled.
Android — AndroidPlatform
Section titled “Android — AndroidPlatform”Resolve the concrete AndroidPlatform (or use Host.Platform) for the Android-specific helpers Shiny’s modules are built on.
Runtime permissions
Section titled “Runtime permissions”var result = await platform.RequestPermissions( Manifest.Permission.AccessFineLocation, Manifest.Permission.AccessCoarseLocation);if (!result.IsSuccess()){ var fineGranted = result.IsGranted(Manifest.Permission.AccessFineLocation);}
// single permission, mapped to AccessStatevar state = await platform.RequestAccess(Manifest.Permission.Camera);
// no prompt, just the current statevar current = platform.GetCurrentPermissionStatus(Manifest.Permission.Camera);- If every permission is already granted, the request returns immediately without showing a dialog.
- A request needs a foreground
Activity. If none is up yet (for example, the request fired during startup), Shiny waits up to 5 seconds for one to resume and then throwsTimeoutException. That timeout applies only to getting an activity. Once the dialog is showing, the user can take as long as they want. Pass aCancellationTokento give up yourself. GetCurrentPermissionStatusreturnsUnknownrather thanDeniedfor a permission the app has never requested. Shiny records requested permissions in the default store so that the distinction survives a restart.RequestFilteredPermissions(params AndroidPermission[])andEnsureAllManifestEntries(...)takeAndroidPermission(permission, minSdk, maxSdk)records and skip entries that don’t apply to the running API level. Use them for permissions that were added or retired across Android versions.IsInManifest(permission)checks the merged manifest.
Activities
Section titled “Activities”| Member | Description |
|---|---|
CurrentActivity |
The most recently active Activity, or null |
ActivityChanged |
Event raised with an ActivityChanged(Activity, ActivityState, Bundle?) for Created, Started, Resumed, Paused, Stopped, SaveInstanceState and Destroyed |
WaitForActivity(state, ct) |
Completes the next time any activity reaches state (Resumed by default) |
StartActivityForResult(intent, ct) |
Starts an activity from the current one and awaits its ActivityResult(Activity, RequestCode, ResultCode, Data) - a role request, a settings screen, a consent dialog. Waits up to 5s for an activity to appear; no limit on the user |
ActivityResultReceived |
Event raised with every ActivityResult the host forwards, whoever started the activity |
Activity results reach Shiny through the host: Shiny.Hosting.Maui forwards them automatically, and with
Shiny.Hosting.Native the activity derives from ShinyAndroidActivity. To handle them from your own service
instead of the event, implement IAndroidLifecycle.IOnActivityResult and register it in DI.
var roles = (RoleManager)platform.AppContext.GetSystemService(Context.RoleService)!;var result = await platform.StartActivityForResult(roles.CreateRequestRoleIntent(RoleManager.RoleCallScreening));if (result.IsOk){ // the user granted the role}Services, intents and resources
Section titled “Services, intents and resources”| Member | Description |
|---|---|
AppContext |
The Android Application |
StartService(type, stopWithTask) / StopService(type) |
Start or stop a Shiny foreground service. Uses StartForegroundService on API 31+ |
RequestForegroundServicePermissions() |
Requests FOREGROUND_SERVICE and, on API 33+, POST_NOTIFICATIONS. Returns Restricted when only the notification permission was refused |
CreateIntent<T>(actions) / GetBroadcastPendingIntent<T>(...) |
Intent helpers. GetPendingIntentFlags adds Mutable on API 31+ |
RegisterBroadcastReceiver<T>(exported, actions) |
Registers a receiver with the exported flag Android 14 requires |
GetSystemService<T>(name) / GetSystemServiceValue(...) |
Typed system-service access |
GetNotificationIconResource() |
Returns the notification drawable, or the app icon if there isn’t one. Throws if neither exists |
GetResourceIdByName, GetColorByName, GetRawResourceIdByName |
Resource lookups by name |
Apple — IosPlatform and AppleExtensions
Section titled “Apple — IosPlatform and AppleExtensions”AppleExtensions collects the helpers Shiny’s Apple backends use:
| Member | Description |
|---|---|
IsSimulator |
true on the iOS/tvOS simulator, always false on Mac Catalyst/macOS |
HasBackgroundMode("location") |
Checks UIBackgroundModes in Info.plist |
AssertInfoPlistEntry(key, assert) / HasPlistValue(key, ifVersion) |
Info.plist checks, optionally only from an OS version onward |
HasAppDelegateHook(selector) / AssertAppDelegateHook(...) |
Checks that the AppDelegate implements a selector, for example didRegisterForRemoteNotificationsWithDeviceToken |
ToDateTime() / ToNSDate() |
NSDate conversions (Apple’s reference date is 2001-01-01 UTC) |
ToGuid() / ToNSUuid() |
NSUuid conversions |
FromNsDictionary() / ToNsDictionary() |
String dictionary conversions |
Windows
Section titled “Windows”Shiny.Permissions.IsInMainfest(capability) reads AppxManifest.xml to check whether a DeviceCapability is declared. It only applies to packaged apps.


