runtime: OperatingSystem.IsIOS API is problematic
Background and Motivation
.NET 6 heavily relies on the platform compatibility analyzer, linker and operating system detection on the mobile platforms. Currently there’s a parity between the values returned by OperatingSystem.IsXXX()
APIs, the UnsupportedOSPlatform("xxx")
and attributes. The target framework moniker (TFM) also uses the same XXX
syntax for platform suffix. All the OperatingSystem.IsXXX()
APIs are mutually exclusive and at most one of them returns true
on a given platform.
Unlike most TFMs the Mac Catalyst has an implicit relationship with the iOS TFM. Application targeting net6.0-maccatalyst
may consume library assets that were built with net6.0-ios
TFMs. This creates a disparity where this relationship is not captured by the OperatingSystem.IsIOS/IsMacCatalyst
APIs and the unavailable Mac Catalyst APIs have to include explicit UnsupportedOSPlatform("maccatalyst")
annotations even though they don’t target net6.0-maccatalyst
directly. Failure to do so would currently be silently ignored and a transitive library consumption will not produce Platform Compatibility Analyzer warnings.
Additionally, libraries targeting net6.0
and including iOS specific logic can easily fall into a trap of guarding the code with OperatingSystem.IsIOS()
when the correct condition is OperatingSystem.IsIOS() || OperatingSystem.IsMacCatalyst()
.
Similarly, in native C / Objective-C / Swift code the platform availability guards implicitly imply the Mac Catalyst as a variant of iOS.
Platform guard example in C
Consider the following C code:
#include <stdio.h>
int main()
{
#if __is_target_os(ios)
printf("__is_target_os(ios): true\n");
#endif
if (__builtin_available(iOS 16, *)) {
printf("__builtin_available iOS 16\n");
}
if (__builtin_available(iOS 10, *)) {
printf("__builtin_available iOS 10\n");
}
if (__builtin_available(iOS 16, macCatalyst 11, *)) {
printf("__builtin_available macCatalyst\n");
}
}
It can be compiled for Mac Catalyst by running clang -target x86_64-apple-ios13.0-macabi avail.c -o avail
and it produces the following output:
__is_target_os(ios): true
__builtin_available iOS 10
__builtin_available macCatalyst
The interpretation is that __is_target_os(...)
treats Mac Catalyst as iOS variant. __builtin_available
uses the iOS <version>
value on Mac Catalyst unless an explict check is specified.
Proposed solutions
Proposal A
OperatingSystem.IsIOS()
would returntrue
on both Mac Catalyst and iOS. In majority of the cases that is what the developer wants to check since Mac Catalyst is supposed to be a superset of iOS. Current runtime checks that doOperatingSystem.IsIOS() || OperatingSystem.IsTvOS() || OperatingSystem.IsMacCatalyst()
would be shortened toOperatingSystem.IsIOS() || OperatingSystem.IsTvOS()
.- Keep
UnsupportedOSPlatform("XXX")
consistent withOperatingSystem.IsXXX
, both in the Platform Compatibility Analyzer and in linker. Thus specifyingUnsupportedOSPlatform("ios")
would imply that an API is also unsupported on Mac Catalyst. Duplicate UnsupportedOSPlatform(“ios”) and UnsupportedOSPlatform(“maccatalyst”) attributes would coalesce into one. - For the rare case where you actually want to behave differently on iOS and MacCatalyst you would use a combination of the checks / attributes. An iOS-only API would be decorated with
[UnsupportedOSPlatform("maccatalyst")]
and runtime check would be!OperatingSystem.IsMacCatalyst()
. A MacCatalyst-only API would be decorated with[UnsupportedOSPlatform("ios")]
and[SupportedOSPlatform("maccatalyst")]
(or similar). Code block guarding specifically for iOS and not Mac Catalyst would useOperatingSystem.IsIOS() && !OperatingSystem.IsMacCatalyst()
.
Proposal B
-
Add
OperatingSystem.IsIOSOrMacCatalyst()
API with appropriateUnsupportedOSPlatformGuard
attributes. This would simplify the checks in code while keeping theOperatingSystem.IsXXX
APIs more consistent. There’s a potential error for the caller to keep usingIsIOS
whereIsIOSOrMacCatalyst
should have been used. Casual observation suggests that most of theIsIOS()
API usages in .NET runtime itself would be replaceable with this alternate API since they doIsIOS() || IsMacCatalyst()
check anyway. -
Teach the Platform Compatibility analyzer about the additional TFM relationship and enforce additional rules when targeting
net6.0-ios
and not targetingnet6.0-maccatalyst
in a library code (ie. adding explicit supported/unsupported MacCatalyst annotations where iOS annotations are present; additional checks for use of theIsIOS()
API). [TODO]
Additional design considerations
- Should
OperatingSystem.IsXXX
map the TFM fallbacks in general? - Should there be a relation to how RIDs are structured too?
- Should
IsLinux()
returntrue
on Android? Likely not; the API surface is significantly different, there’s prior art with Flutter:This value is false if the operating system is a specialized version of Linux that identifies itself by a different name, for example Android (see isAndroid).
/cc @terrajobst @jeffhandley for design decisions
Kudos to @filipnavara for the write-up.
About this issue
- Original URL
- State: closed
- Created 3 years ago
- Reactions: 4
- Comments: 36 (35 by maintainers)
I’ve been exploring options for this and I have an updated proposal. I found that the Platform Compatibility Analyzer does not currently respect
SupportedOSPlatformGuard
attributes on theSystem.OperatingSystem
methods; it identifies this type specifically and infers the method’s platform guard. I also found flaws in the proposal of adding[SupportedOSPlatformGuard("ios")]
toIsMacCatalyst
, but the updated proposal is similar and addresses the flaws.Acceptance Criteria
To consider the proposal of using
SupportedOSPlatformGuard
attributes on theOperatingSystem
methods to achieve a relationship of MacCatalyst as a superset of IOS, we will utilize the following acceptance criteria.OperatingSystem.IsIOS()
matches both IOS and MacCatalystOperatingSystem.IsIOS() && !OperatingSystem.IsMacCatalyst()
matches only IOS but not MacCatalystOperatingSystem.IsMacCatalyst()
matches only MacCatalyst but not IOS[SupportedOSPlatformGuard]
attributes is consistent with existing behavior[UnsupportedOSPlatform]
and[UnsupportedOSPlatformGuard]
attributes must continue to work as expected[UnsupportedOSPlatform]
attributes must respect the platform relationship definedThere are a few other design considerations as well, although they are not required goals.
OperatingSystem
methods should be treated like any other method with[SupportedOSPlatformGuard]
attributesProposal Summary
OperatingSystem.IsIOS
to returntrue
when the platform is “ios” OR “maccatalyst”[SupportedOSPlatformGuard]
attributes onSystem.OperatingSystem
OperatingSystem.IsIOS
with[SupportedOSPlatformGuard("ios")]
and [SupportedOSPlatformGuard("maccatalyst")]
OperatingSystem.Is{Platform}
methods with[SupportedOSPlatformGuard("{Platform}")]
attributes[SupportedOSPlatformGuard]
attributes on that platform’sIs{Platform}
methodIsOsx
member onOperatingSystem
(either private or[Obsolete]
)Illustration of Acceptance Criteria
The following code simulates this behavior by using custom guard properties with
[SupportedOSPlatformGuard]
attributes and showing where[SupportedOSPlatform]
and[UnsupportedOSPlatform]
attributes would be inferred by the updated logic.This approach and design meets all of the acceptance criteria defined above.
IsIOS()
)[SupportedOSPlatformGuard]
attributes is consistent with existing behavior[UnsupportedOSPlatform]
and[UnsupportedOSPlatformGuard]
attributes must continue to work as expectedSupportedOSPlatformGuard
annotations during initializationThe other design considerations can all be achieved as well.
OperatingSystem
methods should be treated like any other method withSupportedOSPlatformGuard
attributesOperatingSystem
implementations to augment/override built-in behaviorIsOSX()
method is added toOperatingSystem
(private or[Obsolete]
)The lengthy discovery process that led to this proposal
SupportedOSPlatformGuard Attribute Behavior
I needed a refresher for how the guard attributes are applied. Let’s start a basic single-platform scenario where the guard attribute is useful.
Single Platform without the Guard Attribute
Abstracting away the OperatingSystem Check
Adding a Second Platform
In this case,
DoWork
is supported on both “android” and “ios” andIsSupported
asserts that the current platform is either of those through an OR condition.Differentiating Between Platforms
Making the scenario more complex, we can expand this out such that there’s one implementation for the Apple platforms and a different implementation for Android.
To accomplish this, we will keep
IsSupported
as the higher-level check, but use additional guard methods to identify the Apple platforms.In this scenario:
IsApplePlatform
istrue
for “ios” OR “maccatalyst”IsSupported
istrue
if the current platform is “android” OR “ios” OR “maccatalyst”This scenario illustrates why the guards must be treated with OR logic.
MacCatalyst or IOS
Here is the behavior we desire to allow a single check for either MacCatalyst or IOS:
In this table,
IsIOS()
should returntrue
for either “ios” or “maccatalyst”, butIsMacCatalyst()
should only returntrue
on “maccatalyst”. In earlier comments on this issue, we were proposing the following:However, this is the inverse of what was actually intended. If we want
IsIOS()
to returntrue
for either “ios” or “maccatalyst”, then our annotations should instead be:With those annotations,
IsIOS()
would match the behavior of the multi-platform guard methods illustrated above, returningtrue
for either of the platforms. TheIsMacCatalyst()
method is annotated to only returntrue
if the platform is “maccatalyst” (but not “ios”).Current Guard Behavior
The current guard behavior for this configuration can be observed by using custom guard methods with the illustrated annotations.
Within the
if (IsIOS)
guard, the code is understood to be reachable on either “ios” OR “maccatalyst”. That implies that all code within the block needs to be supported on both of those platforms, and that’s not the case.Within the
if (IsMacCatalyst)
guard, the code is understood to be reachable only on “maccatalyst” (and not traditional “ios”)–which is the accurate interpretation of asserting that we are on the superset platform. As is, that implies code within the block must be supported on “maccatalyst”, butSupportedOnIOS
is not.The proposal above included changing the behavior of the second point such that when
IsMacCatalyst()
returnstrue
, references to APIs supported only on “ios” should succeed, with the analyzer recognizing a relationship between these platforms.Recognizing the Platform Relationship
We asserted above that we could glean the relationship between “maccatalyst” and “ios” by finding an annotation of
[SupportedOSPlatformGuard("ios")]
onIsMacCatalyst()
. As is illustrated here though, that annotation would be backwards.Two alternatives to illustrate based on the proposal are:
SupportedOSPlatformGuard
annotationsIsIOS()
insteadInverting the Annotations
If we invert the annotations in the sample, we would achieve the correct results within the
if (IOS)
block, but we still don’t achieve the correct results within theif (IsMacCatalyst)
block.On the surface, this result seems close enough to the desired effect that the analyzer could be updated to understand the relationship between “maccatalyst” and “ios” and suppress the two incorrect warnings. The logic would be:
OperatingSystem
method, check that method forSupportedOSPlatformGuard
annotationsThis would have the effect of applying AND logic between the platforms, which would be equivalent to changing the code above to:
Everything in this example is correct, but there is a flaw in this approach when we look back at the early examples of how guards are interpreted with OR logic: this behavior would have to be special-cased to apply only for methods on the
OperatingSystem
class and behave differently elsewhere. This violates the acceptance criteria of the newSupportedOSPlatformGuard
attributes being handled consistently with existing behavior.Glean the relationship from the annotations on
IsIOS()
insteadLet’s look back at the previous example to see how we could check the
IsIOS()
method for annotations about additional platforms.Given the numbered scenarios, the behavior would need to be:
OperatingSystem.IsMacCatalyst()
method.SupportedOSPlatformGuard
attributes on the method.OperatingSystem.IsIOS()
method.SupportedOSPlatformGuard
attributes on the method.OperatingSystem.IsIOS()
method.SupportedOSPlatformGuard
attributes on the method.Generalizing the Behavior
In order for this behavior to work, we would introduce behavior into the analyzer such that:
OperatingSystem.Is{Platform}()
methodSupportedOSPlatformGuard
attributes for other platformsAn alternate approach to the implementation could be done at the time of identifying an API’s supported/unsupported platforms.
OperatingSystem.Is{Platform}()
methodSupportedOSPlatformGuard
attributesWe can simulate this approach by explicitly applying the attributes that would be inferred. This example adds scenarios for the other acceptance criteria as well, including the scenario of an API being marked as supported on IOS but not MacCatalyst.
Here’s a representation of what I think can be inferred for the guard attributes:
Agreed, that’s inescapable IMHO.
Agreed
@buyaa-n Correct.
Note that there could be some APIs (none in dotnet/runtime AFAIK; few in Xamarin bindings) which exist on iOS and don’t exist in Mac Catalyst. In that case I would expect an annotation like this to work:
This is mostly covering frameworks that don’t make sense outside of the phone environment (CoreTelephony, CoreNFC, ARKit, VisionKit, HealthKit, HealthKitUI, AddressBookUI, CarPlay, …). It’s feasible that some of them may appear at later time if Apple decides to do so.
I guess the same apply to the attributes:
[SupportedOSPlatform("iOS")]
coveriOS
andMacCatalyst
[SupportedOSPlatform("maccatalyst")]
only coverMacCatalyst
[UnsupportedOSPlatform("iOS")]
coveriOS
andMacCatalyst
[UnsupportedOSPlatform("maccatalyst")]
only coverMacCatalyst
Here’s my understanding of the desired experience:
@filipnavara does that match your expectations?
Note that the Android/Linux behavior has already been changed in #53034.
It is. There are multiple versions available on MacCatalyst (Darwin, macOS, iOS) but this API and Evironment.OSVersion operates on the iOS one (consistent with Apple and documentation).
My vote would be for Proposal A since it matches the underlying platform model.
Thanks everyone!
For step 4 filed an issue, this one can be closed now
Makes sense, @buyaa-n; thanks. I forgot that the guards produce OR logic.
Thanks, @jeffhandley, all looked right initially, but now I think the section covering the
SupportedOSPlatformGuard
part needs update because multipleSupportedOSPlatformGuard
attributes produce OR logicDo guard methods provide the same stacking behavior as the supported attributes do? If so, then @jeffhandley’s logic should apply to guard methods too.
In fact, they probably should be mirrors because the guard methods are supposed to be used before calling the annotated APIs, so I’d say it would be odd if guard attributes can’t be stacked like this.
Modified Illustration of Acceptance Criteria
I have modified the proposal with exact use case scenarios with have agreed to add within 6.0 (like excluding macOS/OSX scenario we moved to 7.0).
That seems fine because if you target .NET 5 you can’t see
OperatingSystem.IsMacCatalyst()
anyway.Gotcha; thanks, @terrajobst. OK, I’m doing a proof of concept of the guard attributes being applied to the methods to make sure the analyzer will respect those as implemented now. I’ll follow up once I have those findings and we can explore what it would look like for the analyzer to consume that data (during initialization) to understand platform aliases/relationships.
One question though…
Should these relationships be defined within the runtime, or within the tooling? In other words, if the analyzer gets the data from the
OperatingSystem
methods, then the relationship wouldn’t be understood when targeting net5.0.OperatingSystem
methods – ✔️OperatingSystem
methods, yes – ✔️So the trick is how to add the support to the analyzer to understand the platform relationships. I’d prefer for that to be data-driven and not have those specific relationships coded into the analyzer. Are you proposing the analyzer would read the guard attributes in the
OperatingSystem
class to glean the relationships?Yes