dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
15.24k stars 4.73k forks source link

Guarding calls to platform-specific APIs #33331

Closed terrajobst closed 4 years ago

terrajobst commented 4 years ago

For iOS and Android in particular we want the developer to be able to do runtime checks for the OS version in order to guard method calls. We've steered people away from Environment.OSVersion in favor of RuntimeInformation.IsOSPlatform(). However, we (deliberately) omitted a way to detect version numbers because the guidance has been to move to feature detection instead. However, we've learned that version checks are a practical necessity. Also, they are the status quo on iOS and Android.

We plan on combining these guards with a set of custom attributes that are used by an analyzer to flag code that isn't properly guarded. For more details, see this spec.

API Proposal

namespace System.Runtime.InteropServices
{
    public partial struct OSPlatform
    {
        // Existing properties
        // public static OSPlatform FreeBSD { get; }
        // public static OSPlatform Linux { get; }
        // public static OSPlatform OSX { get; }
        // public static OSPlatform Windows { get; }
        public static OSPlatform Android { get; }
        public static OSPlatform iOS { get; }
        // public static OSPlatform macOS { get; } /* We already have OSX */
        public static OSPlatform tvOS { get; }
        public static OSPlatform watchOS { get; }
    }

    public partial static class RuntimeInformation
    {
        // Existing API
        // public static bool IsOSPlatform(OSPlatform osPlatform);

        // Check for the OS with a >= version comparison
        // Used to guard APIs that were added in the given OS release.
        public static bool IsOSPlatformOrLater(OSPlatform osPlatform, int major);
        public static bool IsOSPlatformOrLater(OSPlatform osPlatform, int major, int minor);
        public static bool IsOSPlatformOrLater(OSPlatform osPlatform, int major, int minor, int build);
        public static bool IsOSPlatformOrLater(OSPlatform osPlatform, int major, int minor, int build, int revision);

        // Allows checking for the OS with a < version comparison
        // Used to guard APIs that were obsoleted or removed in the given OS release. The comparison
        // is less than (rather than less than or equal) so that people can pass in the version where
        // API became obsoleted/removed.
        public static bool IsOSPlatformEarlierThan(OSPlatform osPlatform, int major);
        public static bool IsOSPlatformEarlierThan(OSPlatform osPlatform, int major, int minor);
        public static bool IsOSPlatformEarlierThan(OSPlatform osPlatform, int major, int minor, int build);
        public static bool IsOSPlatformEarlierThan(OSPlatform osPlatform, int major, int minor, int build, int revision);
    }
}

namespace System.Runtime.Versioning
{
    // Base type for all platform-specific attributes. Primarily used to allow grouping
    // in documentation.
    public abstract class PlatformAttribute : Attribute
    {
        protected PlatformAttribute (string platformName);
        public string PlatformName { get; }
    }

    // Records the platform that the project targeted.
    [AttributeUsage(AttributeTargets.Assembly,
                    AllowMultiple=false, Inherited=false)]
    public sealed class TargetPlatformAttribute : PlatformAttribute
    {
        public TargetPlatformAttribute(string platformName);
        public string PlatformName { get; }
    }

    // Records the minimum platform that is required in order to the marked thing.
    //
    // * When applied to an assembly, it means the entire assembly cannot be called
    //   into on earlier versions. It records the TargetPlatformMinVersion 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 MinimumPlatformAttribute
    // 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.Event |
                    AttributeTargets.Method |
                    AttributeTargets.Module |
                    AttributeTargets.Property |
                    AttributeTargets.Struct,
                    AllowMultiple=true, Inherited=false)]
    public sealed class MinimumPlatformAttribute : PlatformAttribute
    {
        public MinimumPlatformAttribute(string platformName);
    }

    // Marks APIs that were removed in a given operating system version.
    //
    // Primarily used by OS bindings to indicate APIs that are only available 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 RemovedInPlatformAttribute : PlatformAttribute
    {
        public RemovedInPlatformAttribute(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 ObsoletedInPlatformAttribute : PlatformAttribute
    {
        public ObsoletedInPlatformAttribute(string platformName);
        public string Url { get; set; }
    }
}

This design allows us to encapsulate the version comparison, i.e. the "and later" part.

Usage: Recording Project Properties

<Project>
    <Properties>
        <TargetFramework>net5.0-ios12.0</TargetFramework>
        <TargetPlatformMinVersion>10.0</TargetPlatformMinVersion>
    </Properties>
    ...
</Project>

The SDK already generates a file called AssemblyInfo.cs which includes the TFM. We'll extend on this to also record the target platform and minium version (which can be omitted in the project file which means it's the same as the target platform):

[assembly: TargetFramework(".NETCoreApp, Version=5.0")] // Already exists today
[assembly: TargetPlatform("ios12.0")]  // new
[assembly: MinimumPlatform("ios10.0")] // new

Usage: Guarding Platform-Specific APIs

NSFizzBuzz is an iOS API that was introduced in iOS 14. Since I only want to call the API when I'm running on a version of the OS that supports it I'd guard the call using IsOSPlatformOrLater:

static void ProvideExtraPop()
{
    if (!RuntimeInformation.IsOSPlatformOrLater(OSPlatform.iOS, 14))
        return;

    NSFizzBuzz();
}

Usage: Declaring Platform-Specific APIs

The RemovedInPlatformAttribute and ObsoletedInPlatformAttribute will primarily be used by the OS bindings to indicate whether a given API shouldn't be used any more.

The MinimumPlatformAttribute will be for two things:

  1. Indicate which OS a given assembly can run on (mostly used by user code)
  2. Indicate which OS a given API is supported on (mostly used by OS bindings)

Both scenarios have effectively the same meaning for our analyzer: calls into the assembly/API are only legal if the call site is from the given operating system, in the exact or later version.

The second scenario can also be used by user code to forward the requirement. For example, imagine the NSFizzBuzz API to be complex. User code might want to encapsulate it's usage in a helper type:

[MinimumPlatform("ios14.0")]
internal class NSFizzBuzzHelper
{
    public void Fizz() { ... }
    public void Buzz() { ... }
}

As far as the analyzer is concerned, NSFizzBuzzHelper can only be used on iOS 14, which means that its members can call iOS 14 APIs without any warnings. The requirement to check for iOS is effectively forwarded to code that calls any members on NSFizzBuzzHelper.

@dotnet/fxdc @mhutch