File: Commands\TrayLifecycleService.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Runtime.InteropServices;
using Aspire.Cli.Acquisition;
using Aspire.Cli.Bundles;
using Aspire.Cli.Layout;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Aspire.Shared;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Commands;
 
/// <summary>
/// Invokes the bundled tray's acknowledged lifecycle helpers while protecting their payload.
/// </summary>
internal sealed class TrayLifecycleService(
    IBundleService bundleService,
    LayoutProcessRunner processRunner,
    IEnvironment environment,
    IProcessPathProvider processPathProvider,
    TimeProvider timeProvider,
    ILogger<TrayLifecycleService> logger)
{
    internal static TimeSpan HelperTimeout { get; } = TimeSpan.FromSeconds(30);
 
    public async Task<CommandResult> ExecuteAsync(bool start, CancellationToken cancellationToken)
    {
        if ((!environment.IsMacOS() && !environment.IsWindows()) ||
            RuntimeInformation.ProcessArchitecture is not (Architecture.X64 or Architecture.Arm64))
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand, TrayCommandStrings.UnsupportedPlatform);
        }
 
        string? cliPath = null;
        string? startupCliPath = null;
        if (start)
        {
            var processPath = processPathProvider.ProcessPath;
            // A managed development entrypoint may be dotnet itself, or a runtime-dependent
            // apphost. Neither is the self-contained invoking CLI promised to the companion.
            if (!processPathProvider.IsNativeAot || string.IsNullOrEmpty(processPath) ||
                !Path.IsPathFullyQualified(processPath) || !File.Exists(processPath))
            {
                return CommandResult.Failure(CliExitCodes.InvalidCommand, TrayCommandStrings.NativeCliRequired);
            }
 
            // Capture the actual executable once; never re-resolve PATH or install a private CLI.
            cliPath = Path.GetFullPath(CliPathHelper.ResolveSymlinkOrOriginalPath(processPath, logger));
            startupCliPath = GetStartupCliPath(processPath, cliPath);
        }
 
        var action = start ? "start" : "stop";
        try
        {
            // Extraction and lease acquisition are atomic with respect to version cleanup.
            // Keep this lease until the short-lived helper has acknowledged UI readiness and
            // the GUI's own lease (start), or graceful GUI shutdown (stop), and has exited.
            using var lease = await bundleService.EnsureExtractedAndAcquireLayoutAsync(
                "cli", $"tray {action}", cancellationToken).ConfigureAwait(false);
            var layout = lease?.Layout;
            if (lease is null || !lease.HasLease || layout?.LayoutPath is not { } bundleRoot ||
                !Path.IsPathFullyQualified(bundleRoot))
            {
                return CommandResult.Failure(CliExitCodes.InvalidCommand, TrayCommandStrings.BundleRequired);
            }
 
            var trayPath = layout.GetTrayPath();
            if (trayPath is null || !Path.IsPathFullyQualified(trayPath) || !File.Exists(trayPath))
            {
                return CommandResult.Failure(CliExitCodes.InvalidCommand, TrayCommandStrings.PayloadMissing);
            }
 
            if (environment.IsWindows())
            {
                if (!string.Equals(trayPath, Path.Combine(bundleRoot, WindowsTrayPayload.ExecutablePath), StringComparison.OrdinalIgnoreCase))
                {
                    return CommandResult.Failure(CliExitCodes.InvalidCommand, TrayCommandStrings.PayloadMissing);
                }
                WindowsTrayPayload.Validate(Path.GetDirectoryName(trayPath)!,
                    RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "win-arm64" : "win-x64");
            }
 
            string[] arguments = start
                ? startupCliPath is null
                    ? ["start", "--cli", cliPath!, "--bundle-root", bundleRoot]
                    : ["start", "--cli", cliPath!, "--bundle-root", bundleRoot, "--startup-cli", startupCliPath]
                : ["stop"];
            var variables = new Dictionary<string, string>();
            lease.AddEnvironment(variables);
            cancellationToken.ThrowIfCancellationRequested();
            using var timeout = new CancellationTokenSource(HelperTimeout, timeProvider);
            using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
            try
            {
                // Once start launches the detached GUI, cancellation must not kill its helper
                // before the GUI acquires its own lease. The helper has a bounded readiness
                // handshake; after this point wait for it using only our independent deadline.
                var helperToken = start ? timeout.Token : linked.Token;
                var (exitCode, output, error) = await processRunner.RunAsync(
                    trayPath, arguments, workingDirectory: bundleRoot,
                    environmentVariables: variables, ct: helperToken).ConfigureAwait(false);
                if (!string.IsNullOrWhiteSpace(output))
                {
                    logger.LogDebug("Tray helper stdout: {Output}", output.Trim());
                }
                if (!string.IsNullOrWhiteSpace(error))
                {
                    logger.LogDebug("Tray helper stderr: {Error}", error.Trim());
                }
 
                return exitCode == CliExitCodes.Success
                    ? CommandResult.Success()
                    : CommandResult.Failure(exitCode, string.IsNullOrWhiteSpace(error)
                        ? string.Format(CultureInfo.CurrentCulture, TrayCommandStrings.HelperFailed, action, exitCode)
                        : string.Format(CultureInfo.CurrentCulture, TrayCommandStrings.OperationFailed, action, error.Trim()));
            }
            catch (OperationCanceledException) when (timeout.IsCancellationRequested && (start || !cancellationToken.IsCancellationRequested))
            {
                return CommandResult.Failure(CliExitCodes.WaitTimeout, string.Format(CultureInfo.CurrentCulture, TrayCommandStrings.HelperTimedOut, action));
            }
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            logger.LogDebug(ex, "Failed to invoke the tray {Action} helper.", action);
            return CommandResult.Failure(CliExitCodes.InvalidCommand, string.Format(CultureInfo.CurrentCulture, TrayCommandStrings.OperationFailed, action, ex.Message));
        }
    }
 
    private string? GetStartupCliPath(string invokingPath, string resolvedPath)
    {
        var directory = Path.GetDirectoryName(resolvedPath)!;
        var source = InstallSidecarReader.ReadSourceField(Path.Combine(directory, InstallSidecarReader.SidecarFileName));
        // Script/PR installers replace <prefix>/bin/aspire in place. Package-manager
        // binaries (npm node_modules, .NET tools/.store, Homebrew/Nix stores, etc.)
        // may be versioned even when the process is NativeAOT. Do not register those
        // paths or guess a shim from PATH. Preserve a supplied installation symlink
        // only when its actual binary has the stable portable-install contract.
        // https://github.com/microsoft/aspire/blob/main/docs/specs/install-routes.md
        if (source is not (InstallSourceExtensions.ScriptWire or InstallSourceExtensions.PrWire or InstallSourceExtensions.LocalHiveWire)
            || !string.Equals(Path.GetFileName(directory), "bin", StringComparison.OrdinalIgnoreCase)
            || IsPackageStorePath(invokingPath) || IsPackageStorePath(resolvedPath))
        {
            logger.LogDebug("Tray launch-at-sign-in is unavailable: the invoking CLI has no verified stable portable installation entry point.");
            return null;
        }
 
        return Path.GetFullPath(invokingPath);
    }
 
    private static bool IsPackageStorePath(string path)
        => Path.GetFullPath(path).Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
            StringSplitOptions.RemoveEmptyEntries).Any(component =>
                component.Equals("node_modules", StringComparison.OrdinalIgnoreCase)
                || component.Equals(".store", StringComparison.OrdinalIgnoreCase));
}