| File: Agents\Copilot\CopilotAppInstallationDetector.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. namespace Aspire.Cli.Agents.Copilot; /// <summary> /// Detects a GitHub Copilot App installation. /// </summary> internal interface ICopilotAppInstallationDetector { /// <summary> /// Gets the marker that established the App is installed, or <see langword="null"/> when it is not installed. /// </summary> string? GetInstallationMarker(); } /// <summary> /// Detects GitHub Copilot App installations from platform installation markers. /// </summary> /// <remarks> /// The supported installers are published at https://github.com/github/app#install. /// </remarks> internal sealed class CopilotAppInstallationDetector( IEnvironment environment, CliExecutionContext executionContext) : ICopilotAppInstallationDetector { internal const string AgentEnvironmentVariable = "AI_AGENT"; internal const string AgentEnvironmentValue = "github_copilot_app_agent"; private const string AppDirectoryName = "GitHub Copilot"; private const string WindowsExecutableName = "github.exe"; private const string MacOSAppBundleName = "GitHub Copilot.app"; private const string LinuxApplicationsDirectoryName = "applications"; /// <inheritdoc /> public string? GetInstallationMarker() { // Unlike Copilot CLI, the App executable is a single-instance GUI application and a // `--version` launch does not exit. Use install markers instead; the runtime marker also // covers portable/development App builds outside the standard platform directories. if (string.Equals( environment.GetEnvironmentVariable(AgentEnvironmentVariable), AgentEnvironmentValue, StringComparison.Ordinal)) { return AgentEnvironmentVariable; } if (environment.IsWindows()) { var candidatePaths = new List<string>(); AddWindowsCandidate(candidatePaths, environment.GetEnvironmentVariable("LOCALAPPDATA"), includeProgramsDirectory: true); AddWindowsCandidate( candidatePaths, Path.Combine(executionContext.HomeDirectory.FullName, "AppData", "Local"), includeProgramsDirectory: true); AddWindowsCandidate(candidatePaths, environment.GetEnvironmentVariable("ProgramFiles"), includeProgramsDirectory: false); AddWindowsCandidate(candidatePaths, environment.GetEnvironmentVariable("ProgramFiles(x86)"), includeProgramsDirectory: false); return candidatePaths .Distinct(StringComparer.OrdinalIgnoreCase) .FirstOrDefault(File.Exists); } if (environment.IsMacOS()) { return new[] { Path.Combine(executionContext.HomeDirectory.FullName, "Applications", MacOSAppBundleName), Path.Combine(Path.DirectorySeparatorChar.ToString(), "Applications", MacOSAppBundleName), } .FirstOrDefault(Directory.Exists); } if (environment.IsLinux()) { foreach (var applicationsDirectory in GetLinuxApplicationDirectories()) { if (FindLinuxDesktopEntry(applicationsDirectory) is { } desktopEntry) { return desktopEntry; } } } return null; } /// <summary> /// Gets the Linux application directories in freedesktop search order. /// </summary> internal IReadOnlyList<string> GetLinuxApplicationDirectories() { // Desktop entries are XDG data files, so search the user data root before each system data // root. Relative XDG values are invalid and ignored by the freedesktop base-directory spec. // https://specifications.freedesktop.org/basedir/latest/ var applicationsDirectories = new List<string>(); var configuredDataHome = environment.GetEnvironmentVariable("XDG_DATA_HOME"); var dataHome = !string.IsNullOrEmpty(configuredDataHome) && Path.IsPathFullyQualified(configuredDataHome) ? configuredDataHome : Path.Combine(executionContext.HomeDirectory.FullName, ".local", "share"); applicationsDirectories.Add(Path.Combine(dataHome, LinuxApplicationsDirectoryName)); var configuredDataDirectories = environment.GetEnvironmentVariable("XDG_DATA_DIRS"); if (string.IsNullOrEmpty(configuredDataDirectories)) { var rootDirectory = Path.DirectorySeparatorChar.ToString(); applicationsDirectories.Add(Path.Combine(rootDirectory, "usr", "local", "share", LinuxApplicationsDirectoryName)); applicationsDirectories.Add(Path.Combine(rootDirectory, "usr", "share", LinuxApplicationsDirectoryName)); } else { foreach (var dataDirectory in configuredDataDirectories.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) { if (Path.IsPathFullyQualified(dataDirectory)) { applicationsDirectories.Add(Path.Combine(dataDirectory, LinuxApplicationsDirectoryName)); } } } return applicationsDirectories.Distinct(StringComparer.Ordinal).ToArray(); } private static string? FindLinuxDesktopEntry(string applicationsDirectory) { if (!Directory.Exists(applicationsDirectory)) { return null; } string[] desktopFiles; try { desktopFiles = Directory.GetFiles( applicationsDirectory, "*.desktop", SearchOption.TopDirectoryOnly); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { return null; } foreach (var desktopFile in desktopFiles) { try { // DEB/RPM packages and integrated AppImages register a freedesktop entry shaped as: // [Desktop Entry] // Name=GitHub Copilot // Exec=/path/to/github ... // Require both fields so an unrelated text file with the product name is not enough. var lines = File.ReadAllLines(desktopFile); if (lines.Contains("Name=GitHub Copilot", StringComparer.Ordinal) && lines.Any(static line => line.StartsWith("Exec=", StringComparison.Ordinal) && line.Length > "Exec=".Length)) { return desktopFile; } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { // An unreadable desktop entry cannot prove the App is installed; keep checking // other package/AppImage registrations. } } return null; } private static void AddWindowsCandidate( List<string> candidatePaths, string? rootDirectory, bool includeProgramsDirectory) { if (string.IsNullOrWhiteSpace(rootDirectory)) { return; } candidatePaths.Add(Path.Combine( rootDirectory, includeProgramsDirectory ? "Programs" : string.Empty, AppDirectoryName, WindowsExecutableName)); } }