File: FirstRunExperience.cs
Web Access
Project: src\sdk\src\Cli\dotnet\dotnet.csproj (dotnet)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.CommandLine;
using Microsoft.DotNet.Cli.Commands.Hidden.InternalReportInstallSuccess;
using Microsoft.DotNet.Cli.Commands.Workload;
using Microsoft.DotNet.Cli.ShellShim;
#if !CLI_AOT
using Microsoft.DotNet.Cli.Utils.Extensions;
#endif
using Microsoft.DotNet.Cli.Telemetry;
using Microsoft.DotNet.Cli.Utils;
using Microsoft.DotNet.Configurer;
using Microsoft.Extensions.EnvironmentAbstractions;

namespace Microsoft.DotNet.Cli;

/// <summary>
///  The cross-cutting "first run" experience: first-time-use notice, telemetry opt-out message,
///  ASP.NET Core developer certificate, global-tools PATH registration, and the workload integrity
///  check. This lives in one place so the managed CLI entry point (<see cref="Program"/>) and the
///  NativeAOT bridge (<c>NativeEntryPoint</c>) share one source of truth for first-run setup.
///
///  <para>
///  Both entry points build the <em>same</em> <see cref="DotnetFirstTimeUseConfigurer"/> in the same
///  order, so the two paths are easy to compare for parity. The managed CLI performs the full
///  experience in-process. The NativeAOT binary performs almost all of it in-process too - including
///  ASP.NET Core developer-certificate generation, whose <c>CertificateManager</c> uses only
///  NativeAOT-safe BCL cryptography (the crypto PAL is resolved from the host's already-loaded native
///  library). The one first-run action it cannot perform is workload repair, which needs the NuGet
///  engine. When workload repair is still pending, <see cref="Setup"/> defers the whole invocation to
///  the managed CLI - before writing any sentinel - so the managed CLI performs the complete first-run
///  atomically, exactly once. Everything else (global-tools PATH, the first-time-use notice + telemetry
///  message, the NuGet state migration, and the dev certificate) is AOT-safe and runs in-process on both
///  paths. Subsequent invocations observe the sentinels and run fully in-process on the AOT fast path.
///  </para>
/// </summary>
internal static class FirstRunExperience
{
    private static readonly string s_toolPathSentinelFileName = $"{Product.Version}.toolpath.sentinel";

    /// <summary>
    ///  Runs (or, on the AOT path, arranges) the first-run experience for the given parse result.
    /// </summary>
    /// <returns>
    ///  <see langword="true"/> when first-run setup is complete and the caller may proceed to execute
    ///  the command in-process; <see langword="false"/> (AOT only) when the invocation must be deferred
    ///  to the managed CLI so it can perform the parts of first-run that are unavailable in NativeAOT.
    ///  The managed CLI always returns <see langword="true"/>.
    /// </returns>
    public static bool Setup(ParseResult parseResult)
    {
        // Options that perform terminating actions are considered to essentially be subcommands.
        // These are special as they should not run the first-run setup.
        // Example: dotnet --version
        if (parseResult.Action is InvocableOptionAction { Terminating: true })
        {
            return true;
        }

        using var activity = Activities.Source.StartActivity("first-time-use");
        IFirstTimeUseNoticeSentinel firstTimeUseNoticeSentinel = new FirstTimeUseNoticeSentinel();
        IAspNetCertificateSentinel aspNetCertificateSentinel = new AspNetCertificateSentinel();
        string toolPath = Path.Combine(CliFolderPathCalculator.DotnetUserProfileFolderPath, s_toolPathSentinelFileName);
        IFileSentinel toolPathSentinel = new FileSentinel(new FilePath(toolPath));

        var environmentProvider = new EnvironmentProvider();
        bool generateAspNetCertificate = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_GENERATE_ASPNET_CERTIFICATE, defaultValue: true);
        bool addGlobalToolsToPath = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_ADD_GLOBAL_TOOLS_TO_PATH, defaultValue: true);
        bool skipWorkloadIntegrityCheck = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK,
            // Default the workload integrity check skip to true if the command is being ran in CI. Otherwise, false.
            defaultValue: new CIEnvironmentDetectorForTelemetry().IsCIEnvironment());

        var isDotnetBeingInvokedFromNativeInstaller = false;
        // Note: This should not be special cased like this. Determine if we can skip first run setup entirely for this command.
        if (parseResult.CommandResult.Command is InternalReportInstallSuccessCommandDefinition)
        {
            aspNetCertificateSentinel = new NoOpAspNetCertificateSentinel();
            firstTimeUseNoticeSentinel = new NoOpFirstTimeUseNoticeSentinel();
            toolPathSentinel = new NoOpFileSentinel(exists: false);
            isDotnetBeingInvokedFromNativeInstaller = true;
        }

        string[] getStarOperators = ["getProperty", "getItem", "getTargetResult"];
        char[] switchIndicators = ['-', '/'];
        var skipFirstTimeUseCheck = parseResult.CommandResult.Tokens.Any(t =>
            getStarOperators.Any(o =>
                switchIndicators.Any(i => t.Value.StartsWith(i + o, StringComparison.OrdinalIgnoreCase))));

        var isFirstTimeUse = !firstTimeUseNoticeSentinel.Exists() && !skipFirstTimeUseCheck;

#if CLI_AOT
        // The NativeAOT binary omits only the NuGet engine needed to repair workloads. When workload
        // repair is still pending we defer this entire invocation to the managed CLI - before mutating
        // any state - so it performs the complete first-run atomically, exactly once. The workload repair
        // is detected precisely in-process via WorkloadInstallDetector, so we only defer when workloads
        // are actually installed for this feature band instead of on every first run.
        //
        // Dev-certificate generation IS available in NativeAOT: the ASP.NET Core CertificateManager only
        // uses BCL X509/RSA crypto (no reflection/dynamic code), and the underlying crypto PAL is the
        // host's already-loaded native library, so it runs in-process just like the managed CLI.
        bool workloadRepairPending = isFirstTimeUse
            && !skipWorkloadIntegrityCheck
            && WorkloadInstallDetector.HasInstalledWorkloadsForCurrentBand();
        if (workloadRepairPending)
        {
            return false;
        }
#endif

        IAspNetCoreCertificateGenerator aspnetCertificateGenerator = new AspNetCoreCertificateGenerator();

        bool telemetryOptout = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.TELEMETRY_OPTOUT, defaultValue: CompileOptions.TelemetryOptOutDefault);
        bool nologo = environmentProvider.GetEnvironmentVariableAsBool(EnvironmentVariableNames.DOTNET_NOLOGO, defaultValue: false);

        var dotnetFirstRunConfiguration = new DotnetFirstRunConfiguration(
            generateAspNetCertificate,
            telemetryOptout,
            addGlobalToolsToPath,
            nologo,
            skipWorkloadIntegrityCheck);

        var environmentPath = EnvironmentPathFactory.CreateEnvironmentPath(isDotnetBeingInvokedFromNativeInstaller, environmentProvider);
        var reporter = Reporter.Error;
        var dotnetConfigurer = new DotnetFirstTimeUseConfigurer(
            firstTimeUseNoticeSentinel,
            aspNetCertificateSentinel,
            aspnetCertificateGenerator,
            toolPathSentinel,
            dotnetFirstRunConfiguration,
            reporter,
            environmentPath,
            skipFirstTimeUseCheck);

        dotnetConfigurer.Configure();

#if TARGET_WINDOWS
        if (isDotnetBeingInvokedFromNativeInstaller && OperatingSystem.IsWindows())
        {
            DotDefaultPathCorrector.Correct();
        }
#endif

        if (isFirstTimeUse && !skipWorkloadIntegrityCheck)
        {
#if CLI_AOT
            // Reaching here means WorkloadInstallDetector found no installed workloads for this feature
            // band, so the NuGet-based integrity repair has nothing to do. Any actual repair is handled
            // by the managed CLI, which the AOT bridge falls back to for workload commands.
#else
            try
            {
                WorkloadIntegrityChecker.RunFirstUseCheck(reporter);
            }
            catch (Exception)
            {
                // If the workload check fails for any reason, we want to eat the failure and continue running the command.
                reporter.WriteLine(CliStrings.WorkloadIntegrityCheckError.Yellow());
            }
#endif
        }

        return true;
    }
}