Owner Immo Landwerth
With .NET Core, we've made cross-platform development a mainline experience in .NET. Even more so for library authors that use .NET 5. We generally strive to make it very easy to build code that works well across all platforms. Hence, we avoid promoting technologies and APIs that only work on one platform by not putting platform-specific APIs in the default set that is always referenced. Rather, we're exposing them via dedicated packages that the developer has to reference manually.
Unfortunately, this approach doesn't address all concerns:
-
It's hard to reason about transitive platform-specific dependencies. A developer doesn't necessarily know that they took a dependency on a component that makes their code no longer work across all platforms. For example, in principle, a package that sounds general purpose could depend on something platform-specific that now transitively ties the consuming application to a single platform (for example, an IOC container that depends on the Windows registry). Equally, transitively depending on a platform-specific package doesn't mean that the application doesn't work across all platforms -- the library might actually guard the call so that it can provide a slightly better experience on a particular platform.
-
We need to stay compatible with existing APIs. Ideally, types that provide cross-platform functionality shouldn't offer members that only work on specific platforms. Unfortunately, the .NET platform is two decades old and was originally designed to be a great development platform for Windows. This resulted in Windows-specific APIs being added to types that are otherwise cross-platform. A good example are file system and threading types that have methods for setting the Windows access control lists (ACL). While we could provide them elsewhere, this would unnecessarily break a lot of code that builds Windows-only applications, e.g. WinForms and WPF apps.
-
OS platforms are typically targeted using the latest SDK. Even when the project is platform specific, such as
net5.0-ios
ornet5.0-android
, it's not necessarily clear that the called API is actually available. For .NET Framework, we used reference assemblies which provides a different set of assemblies for every version. However, for OS bindings this approach is not feasible. Instead, applications are generally compiled against the latest version of the SDK with the expectations that callers have to check the OS version if they want to run on older versions. See Minimum OS Version for more details.
.NET has always taken the position that providing platform-specific functionality isn't a bug but a feature because it empowers developers to build applications that feel native for the user. That's why .NET has rich interop facilities such as P/Invoke.
At the same time, developers really dislike the notion of "write once and test everywhere" because it results in a delayed feedback loop.
Fortunately .NET has also taken the position that developer productivity is key. This document proposes:
-
A mechanism to annotate APIs as being platform-specific, including the version they got introduced in, the version they got removed in, and the version they got obsoleted in.
-
A Roslyn analyzer that informs developers when they use platform-specific APIs from call sites where the API might not be available.
This work draws heavily from the experience of the API Analyzer and improves on it by making it a first-class platform feature.
Miguel is building Baby Shark, a popular iOS application. He started with .NET
that supported iOS 13 but Apple just released iOS 14, which adds the great
NSFizzBuzz
API that gives his app the extra pop.
After upgrading the app and writing the code, Miguel decides that while the
feature is cool, it doesn't warrant cutting of all his customers who are
currently still on iOS 13. So he edits the project file and sets
SupportedOSPlatformVersion
to 13.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0-ios14.0</TargetFramework>
<SupportedOSPlatformVersion>13.0</SupportedOSPlatformVersion>
</PropertyGroup>
...
</Project>
After doing this, he gets a diagnostic in this code:
'NSFizzBuzz' requires iOS 14 or later.
private static void ProvideExtraPop()
{
NSFizzBuzz();
}
He invokes the Roslyn code fixer which suggests to guard the call. This results in the following code:
private static void ProvideExtraPop()
{
if (OperatingSystem.IsIOSVersionAtLeast(14))
NSFizzBuzz();
}
After upgrading to iOS 14 Miguel also got the following diagnostic:
'UIApplicationExitsOnSuspend' has been deprecated since iOS 13.1
Miguel decides that he'll tackle that problem later as he needs to ship now, so
he invokes the generic "Suppress in code" code fixer which surrounds his code
with #pragma warning disable
.
A few years after his massively successful fizz-buzzed Baby Shark, Apple releases iOS 15. After upgrading Miguel gets the following diagnostic:
'UIApplicationExitsOnSuspend' has been removed since iOS 15.0
Doh! After reading more details, he realizes that he can just stop calling this API because Apple automatically suspends apps leaving the foreground when they don't require background execution. So he deletes the code that calls the API.
Alejandra is working at Fabrikam, where she's tasked with porting their asset management system to .NET 5. The application consists of several web services, a desktop application, and a set of libraries that are shared.
She begins by porting the core business logic libraries to .NET 5. After porting, she's getting a diagnostic in the code below:
'DirectorySecurity' is only supported on 'Windows'
private static string CreateLoggingDirectory()
{
var appDataDirectory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var loggingDirectory = Path.Combine(appDataDirectory, "Fabrikam", "AssetManagement", "Logging");
// Restrict access to the logging directory
var rules = new DirectorySecurity();
rules.AddAccessRule(
new FileSystemAccessRule(@"fabrikam\log-readers",
FileSystemRights.Read,
AccessControlType.Allow)
);
rules.AddAccessRule(
new FileSystemAccessRule(@"fabrikam\log-writers",
FileSystemRights.FullControl,
AccessControlType.Allow)
);
Directory.CreateDirectory(loggingDirectory, rules);
return loggingDirectory;
}
After reviewing the code she realizes that this code only makes sense for cases where the application is installed on Windows. When running on iOS and Android, the machine isn't in shared environment so restricting the log access doesn't make much sense. She modifies the code accordingly:
private static string GetLoggingPath()
{
var appDataDirectory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var loggingDirectory = Path.Combine(appDataDirectory, "Fabrikam", "AssetManagement", "Logging");
if (!OperatingSystem.IsWindows())
{
// Just create the directory
Directory.CreateDirectory(loggingDirectory);
}
else
{
// Create the directory and restrict access using Windows
// Access Control Lists (ACLs).
var rules = new DirectorySecurity();
rules.AddAccessRule(
new FileSystemAccessRule(@"fabrikam\log-readers",
FileSystemRights.Read,
AccessControlType.Allow)
);
rules.AddAccessRule(
new FileSystemAccessRule(@"fabrikam\log-writers",
FileSystemRights.FullControl,
AccessControlType.Allow)
);
Directory.CreateDirectory(loggingDirectory, rules);
}
return loggingDirectory;
}
After that, the diagnostic disappears automatically.
- Developers get diagnostics when they inadvertently use platform-specific APIs.
- All diagnostics are provided on the command line as well as in the IDE
- The developer doesn't have to resort to explicit suppressions to indicate
intent. Idiomatic gestures, such as guarding the call with a platform
check or marking a method as platform-specific, automatically suppress
these diagnostics. However, explicit suppressions via the usual means
(
NoWarn
,#pragma
) will also work.
- The analyzer helps library authors to express their intent so that their
consumers can also benefit from these diagnostics.
- Library authors can annotate their platform-specific APIs without taking a dependency.
- Library consumers don't have to install a NuGet package to get diagnostics when using platform-specific APIs
- The analyzer must be satisfy the quality bar in order to be turned on by
default
- No false positives, low noise.
- Modelling partially portable APIs (that is APIs are applicable to more than
one platform but not all platforms). Those should be modeled as capability
APIs, as as
RuntimeFeature.IsDynamicCodeSupported
for ref emit. This would be a separate analyzer. - Shipping the platform-specific analyzer and/or annotations out-of-band.
OS bindings and platforms-specific BCL APIs will be annotated using a set of custom attributes:
namespace System.Runtime.Versioning
{
// Base type for all platform-specific attributes. Primarily used to allow grouping
// in documentation.
public abstract class OSPlatformAttribute : Attribute
{
private protected OSPlatformAttribute(string platformName);
public string PlatformName { get; }
}
// Records the platform that the project targeted.
[AttributeUsage(AttributeTargets.Assembly,
AllowMultiple=false, Inherited=false)]
public sealed class TargetPlatformAttribute : OSPlatformAttribute
{
public TargetPlatformAttribute(string platformName);
}
// Records the minimum platform that is required in order to use the marked thing.
//
// * When applied to an assembly, it means the entire assembly cannot be called
// into on earlier versions. It records the SupportedOSPlatformVersion property.
//
// * When applied to an API, it means the API cannot be called from an earlier
// version.
//
// In either case, the caller can either mark itself with SupportedOSPlatformAttribute
// or guard the call with a platform check.
//
// The attribute can be applied multiple times for different operating systems.
// That means the API is supported on multiple operating systems.
//
// A given platform should only be specified once.
[AttributeUsage(AttributeTargets.Assembly |
AttributeTargets.Class |
AttributeTargets.Constructor |
AttributeTargets.Enum |
AttributeTargets.Event |
AttributeTargets.Field |
AttributeTargets.Method |
AttributeTargets.Module |
AttributeTargets.Property |
AttributeTargets.Struct,
AllowMultiple = true, Inherited = false)]
public sealed class SupportedOSPlatformAttribute : OSPlatformAttribute
{
public SupportedOSPlatformAttribute(string platformName);
}
// Marks APIs that were removed or are unsupported in a given OS version.
//
// Used by OS bindings to indicate APIs that are only available in
// earlier versions.
[AttributeUsage(AttributeTargets.Assembly |
AttributeTargets.Class |
AttributeTargets.Constructor |
AttributeTargets.Enum |
AttributeTargets.Event |
AttributeTargets.Field |
AttributeTargets.Method |
AttributeTargets.Module |
AttributeTargets.Property |
AttributeTargets.Struct,
AllowMultiple = true, Inherited = false)]
public sealed class UnsupportedOSPlatformAttribute : OSPlatformAttribute
{
public UnsupportedOSPlatformAttribute(string platformName);
}
// Marks APIs that were obsoleted in a given operating system version.
//
// Primarily used by OS bindings to indicate APIs that should only be used in
// earlier versions.
[AttributeUsage(AttributeTargets.Assembly |
AttributeTargets.Class |
AttributeTargets.Constructor |
AttributeTargets.Event |
AttributeTargets.Method |
AttributeTargets.Module |
AttributeTargets.Property |
AttributeTargets.Struct,
AllowMultiple=true, Inherited=false)]
public sealed class ObsoletedInOSPlatformAttribute : OSPlatformAttribute
{
public ObsoletedInPlatformAttribute(string platformName);
public ObsoletedInPlatformAttribute(string platformName, string message);
public string Message { get; }
public string Url { get; set; }
}
}
In .NET Core 1.x and 2.x days, we added very limited ability for customers to
check which OS they were on. We originally didn't even expose
Environment.OSVersion
and we built a new API
RuntimeInformation.IsOSPlatform()
. The assumption was that doing OS checks is
exclusively done for P/invoke or interoperating with native code, which is why
RuntimeInformation
was put in System.Runtime.InteropServices
.
However, guarding calls to OS bindings or avoiding unsupported APIs in Blazor
isn't really a P/invoke scenario. It's seems counterproductive to force these
customers to import the System.Runtime.InteropServices
namespace.
However, we do like the pattern of the RuntimeInformation.IsOSPlatform()
API
int that it answers the question rather than indicating what it is. This design
allows for having both, very specific OS platforms, such as Ubuntu or classes
of platforms, such as Linux because the API can return true
for both
OSPlatform.Linux
and OSPlatform.Ubuntu
.
To check for specific version, we're introducing analogous APIs where the caller supplies the platform and version information and the API will perform the check. We'll allow both string-based checks as well as checks for well-known operating systems to aid in code completion.
Note: While the analyzer will support the generalized string-based versions it assumes that the provided values are either constant or are variable that are initialized in the same method.
namespace System
{
// This type already exists, but it doesn't have any static methods.
public partial class OperatingSystem
{
// Generalized methods
public static bool IsOSPlatform(string platform);
public static bool IsOSPlatformVersionAtLeast(string platform, int major, int minor = 0, int build = 0, int revision = 0);
// Accelerators for platforms where versions don't make sense
public static bool IsBrowser();
public static bool IsLinux();
// Accelerators with version checks
public static bool IsFreeBSD();
public static bool IsFreeBSDVersionAtLeast(int major, int minor = 0, int build = 0, int revision = 0);
public static bool IsAndroid();
public static bool IsAndroidVersionAtLeast(int major, int minor = 0, int build = 0, int revision = 0);
public static bool IsIOS();
public static bool IsIOSVersionAtLeast(int major, int minor = 0, int build = 0, int revision = 0);
public static bool IsMacOS();
public static bool IsMacOSVersionAtLeast(int major, int minor = 0, int build = 0, int revision = 0);
public static bool IsTvOS();
public static bool IsTvOSVersionAtLeast(int major, int minor = 0, int build = 0, int revision = 0);
public static bool IsWatchOS();
public static bool IsWatchOSVersionAtLeast(int major, int minor = 0, int build = 0, int revision = 0);
public static bool IsWindows();
public static bool IsWindowsVersionAtLeast(int major, int minor = 0, int build = 0, int revision = 0);
}
}
To determine the platform context of the call site the analyzer must consider
application of SupportedOSPlatformAttribute
to the containing member, type,
module, or assembly. The first one will determine the platform context, in other
words the assumption is that more scoped applications of the attribute will
restrict the set of platforms.
Note: The analyzer doesn't need to consider MSBuild properties such as
<TargetFramework>
or <TargetPlatform>
because the value of the
<TargetPlatform>
property is injected into the generated AssemblyInfo.cs
file, which will have an assembly-level application of
SupportedOSPlatformAttribute
.
The analyzer can produce three diagnostics:
- '{API}' requires {OS} {OS version} or later.
- '{API}' has been deprecated since {OS} {OS version}.
- '{API}' has been removed since {OS} {OS version}.
Diagnostic (1) can be suppressed via a call to
OperatingSystem.IsXxxVersionAtLeast()
. This should work for for simple cases
where the call is contained inside an if
check but also when the check causes
control flow to never reach the call:
public void Case1()
{
if (OperatingSystem.IsIOSVersionAtLeast(13))
AppleApi();
}
public void Case2()
{
if (!OperatingSystem.IsIOSVersionAtLeast(13))
return;
AppleApi();
}
Diagnostics (2) and (3) are analogous except the guard is provided by a check in the form of:
public void Case3()
{
if (OperatingSystem.IsIOS() && !OperatingSystem.IsIOSVersionAtLeast(13))
AppleApiThatWasRemovedOrObsoletedInVersion13();
}
- Suggest wrapping statement in platform guard
- Suggest annotating the method with an attribute
All diagnostics can be suppressed by surround the call site using a platform
guard. The fixer should offer surrounding the call with an if
check using the
appropriate platform-guard. It's not necessary that the end result compiles. It
is the user's responsibility to adjust control flow and data flow to ensure
variables are declared and initialized appropriately. The value that the fixer
provides here is educational and avoids having to lookup the correct version
numbers.
For diagnostic (1) we should also offer a fixer that allows it to be marked as
platform specific by applying SupportedOSPlatformAttribute
. If the
attribute is already applied, it should offer to change the platform and
version.
We have an existing project dotnet/platform-compat, available as the Microsoft.DotNet.Analyzers.Compatibility NuGet package, but there are several drawbacks:
- The analyzer isn't on by default
- It carries a database of platform-specific framework APIs, which doesn't scale to 3rd parties
- Suppression mechanism is somewhat lacking
This is intended to become the productized version of Microsoft.DotNet.Analyzers.Compatibility.
Specifically, are we going to use the "marketing" version numbers or what the system returns as version numbers?
This is related to the problem that some operating systems, such as Windows, have quirked the OS API to lie to the application in order to make it compatible (because way too often apps get version checks wrong).
The version is part of the reason why we don't expose a version number but instead perform the checking on behalf of the user. In general, the implementation will do whatever it needs to get the real version number.
As far as the reference assembly is concerned, the expectation is that the bindings will be annotated with the version information as documented by the native operating system SDK.
Should the analyzer consider checks again Environment.OSVersion
? Maybe,
but it's not obvious what the operating system is.
We have fixed Environment.OSVersion
to return the correct version numbers.
However, Environment.OSVersion.Platform
returns an enum with largely outdated
platform. We consider the Platform
bogus and should obsolete it.
In general, we discourage folks from using Environment.OSVersion
unless they
need to identity a very specific OS version in order to workaround a bug.
Developers should use OperatingSystem.IsXxxVersionAtLeast()
instead.
We have a extended this feature to cover that.